Compare commits

..

4 Commits

Author SHA1 Message Date
Rishi Ghan
4e53f23e79 Fixing up build errors 2026-04-14 12:51:29 -04:00
Rishi Ghan
91e99c50d9 Troubleshooting vite and fonts 2026-04-14 12:02:47 -04:00
733a453352 👹 Metadata Reconciler WIP 2026-04-13 22:18:51 -04:00
3d88920f39 Cleanup 2026-04-13 20:31:24 -04:00
34 changed files with 2155 additions and 19650 deletions

View File

@@ -1,28 +0,0 @@
module.exports = {
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true // Allows for the parsing of JSX
}
},
plugins: ["@typescript-eslint", "css-modules"],
settings: {
"import/resolver": {
node: {
extensions: [".js", ".jsx", ".ts", ".tsx"]
}
},
react: {
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
}
},
// Fine tune rules
rules: {
"@typescript-eslint/no-var-requires": 0
}
};

View File

@@ -1,4 +1,4 @@
module.exports = {
semi: true,
trailingComma: "all",
export default {
semi: true,
trailingComma: "all",
};

59
eslint.config.js Normal file
View File

@@ -0,0 +1,59 @@
import js from "@eslint/js";
import typescript from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import react from "eslint-plugin-react";
import prettier from "eslint-plugin-prettier";
import cssModules from "eslint-plugin-css-modules";
import storybook from "eslint-plugin-storybook";
export default [
js.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
"@typescript-eslint": typescript,
react,
prettier,
"css-modules": cssModules,
storybook,
},
settings: {
"import/resolver": {
node: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
},
react: {
version: "detect",
},
},
rules: {
...typescript.configs.recommended.rules,
...react.configs.recommended.rules,
...prettier.configs.recommended.rules,
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-explicit-any": "off",
"react/react-in-jsx-scope": "off",
"no-undef": "off",
},
},
{
files: ["**/*.stories.{js,jsx,ts,tsx}"],
rules: {
...storybook.configs.recommended.rules,
},
},
{
ignores: ["dist/**", "node_modules/**", "build/**"],
},
];

View File

@@ -1,10 +1,10 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.cjs',
},
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',

19434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"name": "threetwo",
"version": "0.1.0",
"type": "module",
"description": "ThreeTwo! A good comic book curator.",
"scripts": {
"build": "vite build",
@@ -24,8 +25,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.18",
"@floating-ui/react-dom": "^2.1.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
@@ -75,7 +76,6 @@
"react-sliding-pane": "^7.3.0",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^11.0.5",
"rxjs": "^7.8.2",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"threetwo-ui-typings": "^1.0.14",
@@ -112,12 +112,13 @@
"@types/ellipsize": "^0.1.3",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.5.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"autoprefixer": "^10.4.27",
"docdash": "^2.0.2",
"@eslint/js": "^10.0.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
@@ -138,7 +139,7 @@
"rimraf": "^6.1.3",
"sass": "^1.97.3",
"storybook": "^8.6.17",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"ts-jest": "^29.4.6",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^6.0.2",

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
"postcss-import": {},
"@tailwindcss/postcss": {},

43
src/app.css Normal file
View File

@@ -0,0 +1,43 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
/* Custom Project Fonts */
@font-face {
font-family: "PP Object Sans Regular";
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "PP Object Sans Heavy";
src: url("/fonts/PPObjectSans-Heavy.otf") format("opentype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "PP Object Sans Slanted";
src: url("/fonts/PPObjectSans-Slanted.otf") format("opentype");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "PP Object Sans HeavySlanted";
src: url("/fonts/PPObjectSans-HeavySlanted.otf") format("opentype");
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Hasklig Regular";
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}

View File

@@ -2,7 +2,7 @@ import React, { ReactElement, useEffect } from "react";
import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
import "../assets/scss/App.css";
import "../../app.css";
import { useStore } from "../store";
export const App = (): ReactElement => {

View File

@@ -329,6 +329,7 @@ export const AcquisitionPanel = (
{/* NAME */}
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
<p className="mb-2">
{/* TODO: Switch to Solar icon */}
{type.id === "directory" && (
<i className="fas fa-folder mr-1"></i>
)}

View File

@@ -112,6 +112,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
className="input"
placeholder="SKU"
/>
{/* TODO: Switch to Solar icon */}
<span className="icon is-small is-left">
<i className="fa-solid fa-barcode"></i>
</span>
@@ -128,6 +129,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
className="input"
placeholder="UPC Code"
/>
{/* TODO: Switch to Solar icon */}
<span className="icon is-small is-left">
<i className="fa-solid fa-box"></i>
</span>
@@ -150,6 +152,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
name={"publisher"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-print mr-2"></i> Publisher
</div>
@@ -173,6 +176,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
name={"story_arc"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-book-open mr-2"></i> Story Arc
</div>
@@ -196,6 +200,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
name={"series"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-layer-group mr-2"></i> Series
</div>
@@ -250,6 +255,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
name={`${name}.creator`}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fa-solid fa-ghost"></i> Creator
</div>
@@ -265,6 +271,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
name={`${name}.role`}
metronResource={"role"}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fa-solid fa-key"></i> Role
</div>
@@ -273,6 +280,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
/>
</p>
</div>
{/* TODO: Switch to Solar icon */}
<span
className="icon is-danger mt-2"
onClick={() => fields.remove(index)}

View File

@@ -0,0 +1,522 @@
import React, { ReactElement, useMemo, useState } from "react"
import { Drawer } from "vaul"
import { FIELD_CONFIG, FIELD_GROUPS } from "./reconciler.fieldConfig"
import {
useReconciler,
SourceKey,
SOURCE_LABELS,
RawSourcedMetadata,
RawInferredMetadata,
CanonicalRecord,
} from "./useReconciler"
// ── Source styling ─────────────────────────────────────────────────────────────
const SOURCE_BADGE: Record<SourceKey, string> = {
comicvine: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
metron: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
gcd: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
locg: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
comicInfo: "bg-slate-100 text-slate-700 dark:bg-slate-700/60 dark:text-slate-300",
inferredMetadata: "bg-gray-100 text-gray-700 dark:bg-gray-700/60 dark:text-gray-300",
}
const SOURCE_SELECTED: Record<SourceKey, string> = {
comicvine: "ring-2 ring-blue-400 bg-blue-50 dark:bg-blue-900/20",
metron: "ring-2 ring-purple-400 bg-purple-50 dark:bg-purple-900/20",
gcd: "ring-2 ring-orange-400 bg-orange-50 dark:bg-orange-900/20",
locg: "ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/20",
comicInfo: "ring-2 ring-slate-400 bg-slate-50 dark:bg-slate-700/40",
inferredMetadata: "ring-2 ring-gray-400 bg-gray-50 dark:bg-gray-700/40",
}
/** Abbreviated source names for compact badge display. */
const SOURCE_SHORT: Record<SourceKey, string> = {
comicvine: "CV",
metron: "Metron",
gcd: "GCD",
locg: "LoCG",
comicInfo: "XML",
inferredMetadata: "Local",
}
const SOURCE_ORDER: SourceKey[] = [
"comicvine", "metron", "gcd", "locg", "comicInfo", "inferredMetadata",
]
type FilterMode = "all" | "conflicts" | "unresolved"
// ── Props ──────────────────────────────────────────────────────────────────────
export interface ReconcilerDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
sourcedMetadata: RawSourcedMetadata
inferredMetadata?: RawInferredMetadata
onSave: (record: CanonicalRecord) => void
}
// ── Scalar cell ────────────────────────────────────────────────────────────────
interface ScalarCellProps {
value: string | null
isSelected: boolean
isImage: boolean
isLongtext: boolean
onClick: () => void
}
function ScalarCell({ value, isSelected, isImage, isLongtext, onClick }: ScalarCellProps): ReactElement {
if (!value) {
return <span className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block"></span>
}
return (
<button
onClick={onClick}
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
isSelected
? `border-transparent ${SOURCE_SELECTED[/* filled by parent */ "comicvine"]}`
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
}`}
>
{isImage ? (
<img
src={value}
alt="cover"
className="w-full h-24 object-cover rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
/>
) : (
<span className={`block text-slate-700 dark:text-slate-300 ${isLongtext ? "line-clamp-3 whitespace-normal" : "truncate"}`}>
{value}
</span>
)}
{isSelected && (
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
)}
</button>
)
}
// ── Main component ─────────────────────────────────────────────────────────────
export function ReconcilerDrawer({
open,
onOpenChange,
sourcedMetadata,
inferredMetadata,
onSave,
}: ReconcilerDrawerProps): ReactElement {
const [filter, setFilter] = useState<FilterMode>("all")
const {
state,
unresolvedCount,
canonicalRecord,
selectScalar,
toggleItem,
setBaseSource,
reset,
} = useReconciler(sourcedMetadata, inferredMetadata)
// Derive which sources actually contributed data
const activeSources = useMemo<SourceKey[]>(() => {
const seen = new Set<SourceKey>()
for (const fieldState of Object.values(state)) {
if (fieldState.kind === "scalar") {
for (const c of fieldState.candidates) seen.add(c.source)
} else if (fieldState.kind === "array" || fieldState.kind === "credits") {
for (const item of fieldState.items) seen.add((item as { source: SourceKey }).source)
}
}
return SOURCE_ORDER.filter((s) => seen.has(s))
}, [state])
// Grid: 180px label + one equal column per active source
const gridCols = `180px repeat(${Math.max(activeSources.length, 1)}, minmax(0, 1fr))`
function shouldShow(fieldKey: string): boolean {
const fs = state[fieldKey]
if (!fs) return false
if (filter === "all") return true
if (filter === "conflicts") {
if (fs.kind === "scalar") return fs.candidates.length > 1
if (fs.kind === "array" || fs.kind === "credits") {
const srcs = new Set((fs.items as Array<{ source: SourceKey }>).map((i) => i.source))
return srcs.size > 1
}
return false
}
// unresolved
return (
fs.kind === "scalar" &&
fs.candidates.length > 1 &&
fs.selectedSource === null &&
fs.userValue === undefined
)
}
const allResolved = unresolvedCount === 0
return (
<Drawer.Root open={open} onOpenChange={onOpenChange}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/50 z-40" />
<Drawer.Content
aria-describedby={undefined}
className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900 outline-none"
>
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
{/* ── Header ── */}
<div className="flex-none border-b border-slate-200 dark:border-slate-700 shadow-sm">
{/* Title + controls */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<i className="icon-[solar--refresh-circle-outline] w-5 h-5 text-slate-500 dark:text-slate-400" />
<span className="font-semibold text-slate-800 dark:text-slate-100 text-base">
Reconcile Metadata
</span>
{unresolvedCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
{unresolvedCount} unresolved
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Filter pill */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 gap-0.5">
{(["all", "conflicts", "unresolved"] as FilterMode[]).map((mode) => (
<button
key={mode}
onClick={() => setFilter(mode)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors capitalize ${
filter === mode
? "bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}`}
>
{mode}
</button>
))}
</div>
<button
onClick={reset}
title="Reset all selections"
className="px-3 py-1.5 text-xs rounded-md border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
Reset
</button>
<button
onClick={() => onOpenChange(false)}
title="Close"
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<i className="icon-[solar--close-square-outline] w-5 h-5 block" />
</button>
</div>
</div>
{/* Source column headers */}
<div
className="px-4 pb-3"
style={{ display: "grid", gridTemplateColumns: gridCols, gap: "8px" }}
>
<div className="text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider flex items-end pb-0.5">
Field
</div>
{activeSources.map((src) => (
<div key={src} className="flex flex-col gap-1.5">
<span className={`text-xs font-semibold px-2 py-0.5 rounded w-fit ${SOURCE_BADGE[src]}`}>
{SOURCE_LABELS[src]}
</span>
<button
onClick={() => setBaseSource(src)}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-left transition-colors"
>
Use all
</button>
</div>
))}
</div>
</div>
{/* ── Scrollable body ── */}
<div className="flex-1 overflow-y-auto">
{FIELD_GROUPS.map((group) => {
const fieldsInGroup = Object.entries(FIELD_CONFIG)
.filter(([, cfg]) => cfg.group === group)
.filter(([key]) => shouldShow(key))
if (fieldsInGroup.length === 0) return null
return (
<div key={group}>
{/* Group sticky header */}
<div className="sticky top-0 z-10 px-4 py-2 bg-slate-50 dark:bg-slate-800/90 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
<span className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{group}
</span>
</div>
{/* Field rows */}
{fieldsInGroup.map(([fieldKey, fieldCfg]) => {
const fs = state[fieldKey]
if (!fs) return null
const isUnresolved =
fs.kind === "scalar" &&
fs.candidates.length > 1 &&
fs.selectedSource === null &&
fs.userValue === undefined
return (
<div
key={fieldKey}
className={`border-b border-slate-100 dark:border-slate-800/60 transition-colors ${
isUnresolved ? "bg-amber-50/50 dark:bg-amber-950/20" : ""
}`}
style={{
display: "grid",
gridTemplateColumns: gridCols,
gap: "8px",
padding: "10px 16px",
alignItems: "start",
}}
>
{/* Label column */}
<div className="flex flex-col gap-0.5 pt-1.5 pr-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">
{fieldCfg.label}
</span>
{fieldCfg.comicInfoKey && (
<span className="text-xs text-slate-400 font-mono leading-none">
{fieldCfg.comicInfoKey}
</span>
)}
{isUnresolved && (
<span className="inline-flex items-center gap-0.5 text-xs text-amber-600 dark:text-amber-400 mt-0.5">
<i className="icon-[solar--danger-triangle-outline] w-3 h-3" />
conflict
</span>
)}
</div>
{/* Content — varies by kind */}
{fs.kind === "scalar" ? (
// One cell per active source
activeSources.map((src) => {
const candidate = fs.candidates.find((c) => c.source === src)
const isSelected = fs.selectedSource === src
// For selected state we need the source-specific color
const selectedClass = isSelected ? SOURCE_SELECTED[src] : ""
if (!candidate) {
return (
<span
key={src}
className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block"
>
</span>
)
}
return (
<button
key={src}
onClick={() => selectScalar(fieldKey, src)}
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
isSelected
? `border-transparent ${selectedClass}`
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
}`}
>
{fieldCfg.renderAs === "image" ? (
<img
src={candidate.value}
alt="cover"
className="w-full h-24 object-cover rounded"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
) : (
<span
className={`block text-slate-700 dark:text-slate-300 ${
fieldCfg.renderAs === "longtext"
? "line-clamp-3 whitespace-normal text-xs leading-relaxed"
: "truncate"
}`}
>
{candidate.value}
</span>
)}
{isSelected && (
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
)}
</button>
)
})
) : fs.kind === "array" ? (
// Merged list spanning all source columns
<div
className="flex flex-wrap gap-1.5"
style={{ gridColumn: "2 / -1" }}
>
{fs.items.length === 0 ? (
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
) : (
fs.items.map((item) => (
<label
key={item.itemKey}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-all text-sm select-none ${
item.selected
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
}`}
>
<input
type="checkbox"
checked={item.selected}
onChange={(e) =>
toggleItem(fieldKey, item.itemKey, e.target.checked)
}
className="w-3 h-3 rounded accent-slate-600 flex-none"
/>
<span className="text-slate-700 dark:text-slate-300">
{item.displayValue}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_BADGE[item.source]}`}
>
{SOURCE_SHORT[item.source]}
</span>
</label>
))
)}
</div>
) : fs.kind === "credits" ? (
// Credits spanning all source columns
<div
className="flex flex-col gap-1"
style={{ gridColumn: "2 / -1" }}
>
{fs.items.length === 0 ? (
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
) : (
fs.items.map((item) => (
<label
key={item.itemKey}
className={`inline-flex items-center gap-2 px-2 py-1.5 rounded-md border cursor-pointer transition-all text-sm select-none ${
item.selected
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
}`}
>
<input
type="checkbox"
checked={item.selected}
onChange={(e) =>
toggleItem(fieldKey, item.itemKey, e.target.checked)
}
className="w-3 h-3 rounded accent-slate-600 flex-none"
/>
<span className="font-medium text-slate-700 dark:text-slate-300">
{item.name}
</span>
<span className="text-slate-400 dark:text-slate-500">·</span>
<span className="text-slate-500 dark:text-slate-400 text-xs">
{item.role}
</span>
<span
className={`ml-auto text-xs px-1.5 py-0.5 rounded font-medium flex-none ${SOURCE_BADGE[item.source]}`}
>
{SOURCE_SHORT[item.source]}
</span>
</label>
))
)}
</div>
) : (
// GTIN and other complex types
<div
className="pt-1.5"
style={{ gridColumn: "2 / -1" }}
>
<span className="text-slate-400 dark:text-slate-500 text-sm italic">
Structured field editor coming soon
</span>
</div>
)}
</div>
)
})}
</div>
)
})}
{/* Empty state when filter hides everything */}
{FIELD_GROUPS.every((group) =>
Object.entries(FIELD_CONFIG)
.filter(([, cfg]) => cfg.group === group)
.every(([key]) => !shouldShow(key)),
) && (
<div className="flex flex-col items-center justify-center py-24 gap-3 text-slate-400 dark:text-slate-500">
<i className="icon-[solar--check-circle-bold] w-10 h-10 text-green-400" />
<span className="text-sm">
{filter === "unresolved" ? "No unresolved conflicts" : "No fields match the current filter"}
</span>
</div>
)}
</div>
{/* ── Footer ── */}
<div className="flex-none border-t border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between bg-white dark:bg-slate-900">
<div className="text-sm">
{allResolved ? (
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
<i className="icon-[solar--check-circle-bold] w-4 h-4" />
All conflicts resolved
</span>
) : (
<span className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<i className="icon-[solar--danger-triangle-outline] w-4 h-4" />
{unresolvedCount} field{unresolvedCount !== 1 ? "s" : ""} still need a value
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onOpenChange(false)}
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
onSave(canonicalRecord)
onOpenChange(false)
}}
disabled={!allResolved}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
allResolved
? "bg-green-600 text-white hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600"
: "bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600 cursor-not-allowed"
}`}
>
Save Canonical Record
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}

View File

@@ -1,7 +1,11 @@
import React, { ReactElement, useMemo, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { Drawer } from "vaul";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ComicVineDetails from "../ComicVineDetails";
import { ReconcilerDrawer } from "./ReconcilerDrawer";
import { fetcher } from "../../../graphql/fetcher";
import { useGetComicByIdQuery } from "../../../graphql/generated";
import type { CanonicalRecord } from "./useReconciler";
interface ComicVineMetadata {
volumeInformation?: Record<string, unknown>;
@@ -21,6 +25,7 @@ interface SourcedMetadata {
}
interface VolumeInformationData {
id?: string;
sourcedMetadata?: SourcedMetadata;
inferredMetadata?: { issue?: unknown };
updatedAt?: string;
@@ -31,6 +36,14 @@ interface VolumeInformationProps {
onReconcile?: () => void;
}
const SET_METADATA_FIELD = `
mutation SetMetadataField($comicId: ID!, $field: String!, $value: String!) {
setMetadataField(comicId: $comicId, field: $field, value: $value) {
id
}
}
`;
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
const SOURCED_METADATA_KEYS = [
"comicvine",
@@ -60,55 +73,40 @@ const SOURCE_ICONS: Record<string, string> = {
const MetadataSourceChips = ({
sources,
onOpenReconciler,
}: {
sources: string[];
onOpenReconciler: () => void;
}): ReactElement => {
const [isSheetOpen, setSheetOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
<div className="flex flex-row items-center justify-between">
<span className="text-md text-slate-500 dark:text-slate-400">
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
{sources.length} metadata sources detected
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
<div className="flex flex-row items-center justify-between">
<span className="text-md text-slate-500 dark:text-slate-400">
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
{sources.length} metadata sources detected
</span>
</div>
<div className="flex flex-row flex-wrap gap-2">
{sources.map((source) => (
<span
key={source}
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
>
<i
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
/>
{SOURCE_LABELS[source] ?? source}
</span>
</div>
<div className="flex flex-row flex-wrap gap-2">
{sources.map((source) => (
<span
key={source}
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
>
<i
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
/>
{SOURCE_LABELS[source] ?? source}
</span>
))}
</div>
))}
</div>
<button
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => setSheetOpen(true)}
onClick={onOpenReconciler}
>
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
Reconcile sources
</button>
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
<div className="p-4">
{/* Reconciliation UI goes here */}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</>
</div>
);
};
@@ -127,6 +125,35 @@ export const VolumeInformation = (
props: VolumeInformationProps,
): ReactElement => {
const { data } = props;
const [isReconcilerOpen, setReconcilerOpen] = useState(false);
const queryClient = useQueryClient();
const { mutate: saveCanonical } = useMutation({
mutationFn: async (record: CanonicalRecord) => {
const saves = Object.entries(record)
.filter(([, fv]) => fv != null)
.map(([field, fv]) => ({
field,
value:
typeof fv!.value === "string"
? fv!.value
: JSON.stringify(fv!.value),
}));
await Promise.all(
saves.map(({ field, value }) =>
fetcher<unknown, { comicId: string; field: string; value: string }>(
SET_METADATA_FIELD,
{ comicId: data.id ?? "", field, value },
)(),
),
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: useGetComicByIdQuery.getKey({ id: data.id ?? "" }),
});
},
});
const presentSources = useMemo(() => {
const sources = SOURCED_METADATA_KEYS.filter((key) => {
@@ -151,7 +178,10 @@ export const VolumeInformation = (
return (
<div key={1}>
{presentSources.length > 1 && (
<MetadataSourceChips sources={presentSources} />
<MetadataSourceChips
sources={presentSources}
onOpenReconciler={() => setReconcilerOpen(true)}
/>
)}
{presentSources.length === 1 &&
data.sourcedMetadata?.comicvine?.volumeInformation && (
@@ -160,6 +190,13 @@ export const VolumeInformation = (
updatedAt={data.updatedAt}
/>
)}
<ReconcilerDrawer
open={isReconcilerOpen}
onOpenChange={setReconcilerOpen}
sourcedMetadata={(data.sourcedMetadata ?? {}) as import("./useReconciler").RawSourcedMetadata}
inferredMetadata={data.inferredMetadata as import("./useReconciler").RawInferredMetadata | undefined}
onSave={saveCanonical}
/>
</div>
);
};

View File

@@ -0,0 +1,285 @@
/**
* UI field configuration for the metadata reconciler.
*
* Each entry maps a CanonicalMetadata field key to:
* - label Display name shown in the reconciler table
* - group Which section the field belongs to
* - renderAs How the field's cell is rendered (drives component selection)
* - comicInfoKey The ComicInfo.xml v1 key this field exports to, or null if
* the field has no v1 equivalent (shown with a badge in the UI)
*
* The order of entries within each group controls row order in the table.
*/
export type RenderType =
| "scalar" // Single string/number — click to select
| "date" // ISO date string — click to select
| "longtext" // Multi-line text — click to select, expandable preview
| "image" // Cover image — thumbnail grid picker
| "array" // Flat list of strings with source badges
| "arcs" // [{name, number}] — arc name + position number
| "universes" // [{name, designation}] — universe name + designation
| "credits" // [{name, role}] — role-grouped, toggleable list
| "seriesInfo" // Structured series object — rendered as sub-fields
| "prices" // [{country, amount, currency}]
| "gtin" // {isbn, upc}
| "reprints" // [{description}]
| "urls" // [{url, primary}]
| "externalIDs" // [{source, externalId, primary}]
export type FieldGroup =
| "Identity"
| "Series"
| "Publication"
| "Content"
| "Credits"
| "Classification"
| "Physical"
| "Commercial"
| "External"
/** Ordered list of groups — controls section order in the reconciler table. */
export const FIELD_GROUPS: FieldGroup[] = [
"Identity",
"Series",
"Publication",
"Content",
"Credits",
"Classification",
"Physical",
"Commercial",
"External",
]
export interface FieldConfig {
label: string
group: FieldGroup
renderAs: RenderType
/**
* ComicInfo.xml v1 key this field maps to on export.
* null means the field is not exported to ComicInfo v1.
*/
comicInfoKey: string | null
}
/**
* Master field registry for the reconciler.
* Keys match CanonicalMetadata field names from the core-service GraphQL schema.
*/
export const FIELD_CONFIG: Record<string, FieldConfig> = {
// ── Identity ──────────────────────────────────────────────────────────────
title: {
label: "Title",
group: "Identity",
renderAs: "scalar",
comicInfoKey: null,
},
series: {
label: "Series",
group: "Identity",
renderAs: "scalar",
comicInfoKey: "series",
},
issueNumber: {
label: "Issue Number",
group: "Identity",
renderAs: "scalar",
comicInfoKey: "number",
},
volume: {
label: "Volume",
group: "Identity",
renderAs: "scalar",
comicInfoKey: null,
},
collectionTitle: {
label: "Collection Title",
group: "Identity",
renderAs: "scalar",
comicInfoKey: null,
},
// ── Series ────────────────────────────────────────────────────────────────
seriesInfo: {
label: "Series Info",
group: "Series",
renderAs: "seriesInfo",
comicInfoKey: null,
},
// ── Publication ───────────────────────────────────────────────────────────
publisher: {
label: "Publisher",
group: "Publication",
renderAs: "scalar",
comicInfoKey: "publisher",
},
imprint: {
label: "Imprint",
group: "Publication",
renderAs: "scalar",
comicInfoKey: null,
},
coverDate: {
label: "Cover Date",
group: "Publication",
renderAs: "date",
comicInfoKey: null,
},
storeDate: {
label: "Store Date",
group: "Publication",
renderAs: "date",
comicInfoKey: null,
},
publicationDate: {
label: "Publication Date",
group: "Publication",
renderAs: "date",
comicInfoKey: null,
},
language: {
label: "Language",
group: "Publication",
renderAs: "scalar",
comicInfoKey: "languageiso",
},
// ── Content ───────────────────────────────────────────────────────────────
description: {
label: "Description",
group: "Content",
renderAs: "longtext",
comicInfoKey: "summary",
},
notes: {
label: "Notes",
group: "Content",
renderAs: "longtext",
comicInfoKey: "notes",
},
stories: {
label: "Stories",
group: "Content",
renderAs: "array",
comicInfoKey: null,
},
storyArcs: {
label: "Story Arcs",
group: "Content",
renderAs: "arcs",
comicInfoKey: null,
},
characters: {
label: "Characters",
group: "Content",
renderAs: "array",
comicInfoKey: null,
},
teams: {
label: "Teams",
group: "Content",
renderAs: "array",
comicInfoKey: null,
},
locations: {
label: "Locations",
group: "Content",
renderAs: "array",
comicInfoKey: null,
},
universes: {
label: "Universes",
group: "Content",
renderAs: "universes",
comicInfoKey: null,
},
coverImage: {
label: "Cover Image",
group: "Content",
renderAs: "image",
comicInfoKey: null,
},
// ── Credits ───────────────────────────────────────────────────────────────
creators: {
label: "Credits",
group: "Credits",
renderAs: "credits",
comicInfoKey: null,
},
// ── Classification ────────────────────────────────────────────────────────
genres: {
label: "Genres",
group: "Classification",
renderAs: "array",
comicInfoKey: "genre",
},
tags: {
label: "Tags",
group: "Classification",
renderAs: "array",
comicInfoKey: null,
},
ageRating: {
label: "Age Rating",
group: "Classification",
renderAs: "scalar",
comicInfoKey: null,
},
// ── Physical ──────────────────────────────────────────────────────────────
pageCount: {
label: "Page Count",
group: "Physical",
renderAs: "scalar",
comicInfoKey: "pagecount",
},
format: {
label: "Format",
group: "Physical",
renderAs: "scalar",
comicInfoKey: null,
},
// ── Commercial ────────────────────────────────────────────────────────────
prices: {
label: "Prices",
group: "Commercial",
renderAs: "prices",
comicInfoKey: null,
},
gtin: {
label: "ISBN / UPC",
group: "Commercial",
renderAs: "gtin",
comicInfoKey: null,
},
reprints: {
label: "Reprints",
group: "Commercial",
renderAs: "reprints",
comicInfoKey: null,
},
communityRating: {
label: "Community Rating",
group: "Commercial",
renderAs: "scalar",
comicInfoKey: null,
},
// ── External ──────────────────────────────────────────────────────────────
externalIDs: {
label: "Source IDs",
group: "External",
renderAs: "externalIDs",
comicInfoKey: null,
},
urls: {
label: "URLs",
group: "External",
renderAs: "urls",
comicInfoKey: "web",
},
} as const

View File

@@ -0,0 +1,745 @@
import { useReducer, useMemo } from "react";
import { isNil, isEmpty } from "lodash";
// ── Source keys ────────────────────────────────────────────────────────────────
export type SourceKey =
| "comicvine"
| "metron"
| "gcd"
| "locg"
| "comicInfo"
| "inferredMetadata";
export const SOURCE_LABELS: Record<SourceKey, string> = {
comicvine: "ComicVine",
metron: "Metron",
gcd: "Grand Comics Database",
locg: "League of Comic Geeks",
comicInfo: "ComicInfo.xml",
inferredMetadata: "Local File",
};
// ── Candidate types ────────────────────────────────────────────────────────────
/** One source's value for a scalar field. Multiple candidates for the same field = conflict. */
export interface ScalarCandidate {
source: SourceKey;
value: string;
}
/** One item in an array field (characters, genres, arcs…). Pre-selected; user may deselect. */
export interface ArrayItem {
/** Lowercase dedup key. */
itemKey: string;
displayValue: string;
/** Raw value passed through to the canonical record. */
rawValue: unknown;
source: SourceKey;
selected: boolean;
}
/** One person credit. Dedup key is `"${name}:${role}"` (lowercased). */
export interface CreditItem {
itemKey: string;
id?: string;
name: string;
role: string;
source: SourceKey;
selected: boolean;
}
// ── Per-field state ────────────────────────────────────────────────────────────
/** Unresolved when `selectedSource === null` and `userValue` is absent. */
interface ScalarFieldState {
kind: "scalar";
candidates: ScalarCandidate[];
selectedSource: SourceKey | null;
/** User-typed override; takes precedence over any source value. */
userValue?: string;
}
interface ArrayFieldState {
kind: "array";
items: ArrayItem[];
}
interface CreditsFieldState {
kind: "credits";
items: CreditItem[];
}
interface GTINFieldState {
kind: "gtin";
candidates: Array<{ source: SourceKey; isbn?: string; upc?: string }>;
selectedIsbnSource: SourceKey | null;
selectedUpcSource: SourceKey | null;
}
type FieldState = ScalarFieldState | ArrayFieldState | CreditsFieldState | GTINFieldState;
/** Full reconciler state — one entry per field that has data from at least one source. */
export type ReconcilerState = Record<string, FieldState>;
// ── Raw source data ────────────────────────────────────────────────────────────
/** Raw metadata payloads keyed by source, as stored on the comic document. */
export interface RawSourcedMetadata {
comicvine?: Record<string, unknown>;
/** May arrive as a JSON string; normalised by `ensureParsed`. */
metron?: unknown;
/** May arrive as a JSON string; normalised by `ensureParsed`. */
gcd?: unknown;
locg?: Record<string, unknown>;
/** May arrive as a JSON string; normalised by `ensureParsed`. */
comicInfo?: Record<string, unknown>;
}
/** Metadata inferred from the local file name / path. */
export interface RawInferredMetadata {
issue?: {
name?: string;
number?: number;
year?: string;
subtitle?: string;
};
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function safeString(v: unknown): string | null {
if (isNil(v) || v === "") return null;
return String(v);
}
/** xml2js with `normalizeTags` wraps every value in a single-element array. */
function xmlVal(obj: Record<string, unknown>, key: string): string | null {
const arr = obj[key];
if (!Array.isArray(arr) || arr.length === 0) return null;
return safeString(arr[0]);
}
/** Parse a JSON string if it hasn't been parsed yet. */
function ensureParsed(v: unknown): Record<string, unknown> | null {
if (isNil(v)) return null;
if (typeof v === "string") {
try {
return JSON.parse(v);
} catch {
return null;
}
}
if (typeof v === "object") return v as Record<string, unknown>;
return null;
}
function makeScalarCandidate(
source: SourceKey,
value: unknown,
): ScalarCandidate | undefined {
const val = safeString(value);
return val ? { source, value: val } : undefined;
}
function makeArrayItem(
source: SourceKey,
rawValue: unknown,
displayValue: string,
): ArrayItem {
return {
itemKey: displayValue.toLowerCase().trim(),
displayValue,
rawValue,
source,
selected: true,
};
}
function makeCreditItem(
source: SourceKey,
name: string,
role: string,
id?: string,
): CreditItem {
return {
itemKey: `${name.toLowerCase().trim()}:${role.toLowerCase().trim()}`,
id,
name,
role,
source,
selected: true,
};
}
// ── Source adapters ────────────────────────────────────────────────────────────
type AdapterResult = Partial<Record<string, ScalarCandidate | ArrayItem[] | CreditItem[]>>;
/**
* Extract canonical fields from a ComicVine issue payload.
* Volume info lives under `volumeInformation`; credits under `person_credits` etc.
*/
function fromComicVine(cv: Record<string, unknown>): AdapterResult {
const s: SourceKey = "comicvine";
const vi = cv.volumeInformation as Record<string, unknown> | undefined;
const img = cv.image as Record<string, unknown> | undefined;
const publisher = vi?.publisher as Record<string, unknown> | undefined;
return {
title: makeScalarCandidate(s, cv.name),
series: makeScalarCandidate(s, vi?.name),
issueNumber: makeScalarCandidate(s, cv.issue_number),
volume: makeScalarCandidate(s, vi?.id),
description: makeScalarCandidate(s, cv.description),
publisher: makeScalarCandidate(s, publisher?.name),
coverDate: makeScalarCandidate(s, cv.cover_date),
storeDate: makeScalarCandidate(s, cv.store_date),
coverImage: makeScalarCandidate(s, img?.super_url ?? img?.small_url),
characters: ((cv.character_credits as unknown[]) ?? [])
.filter((c): c is Record<string, unknown> => !isNil(c))
.map((c) => makeArrayItem(s, c, safeString(c.name) ?? "")),
teams: ((cv.team_credits as unknown[]) ?? [])
.filter((t): t is Record<string, unknown> => !isNil(t))
.map((t) => makeArrayItem(s, t, safeString(t.name) ?? "")),
locations: ((cv.location_credits as unknown[]) ?? [])
.filter((l): l is Record<string, unknown> => !isNil(l))
.map((l) => makeArrayItem(s, l, safeString(l.name) ?? "")),
storyArcs: ((cv.story_arc_credits as unknown[]) ?? [])
.filter((a): a is Record<string, unknown> => !isNil(a))
.map((a) => makeArrayItem(s, a, safeString(a.name) ?? "")),
creators: ((cv.person_credits as unknown[]) ?? [])
.filter((p): p is Record<string, unknown> => !isNil(p))
.map((p) =>
makeCreditItem(s, safeString(p.name) ?? "", safeString(p.role) ?? ""),
),
};
}
/**
* Extract canonical fields from a Metron / MetronInfo payload.
* Keys are PascalCase mirroring the MetronInfo XSD schema.
*/
function fromMetron(raw: Record<string, unknown>): AdapterResult {
const s: SourceKey = "metron";
const series = raw.Series as Record<string, unknown> | undefined;
const pub = raw.Publisher as Record<string, unknown> | undefined;
const nameList = (arr: unknown[]): ArrayItem[] =>
arr
.filter((x): x is Record<string, unknown> => !isNil(x))
.map((x) => makeArrayItem(s, x, safeString(x.name) ?? ""));
return {
title: makeScalarCandidate(s, (raw.Stories as unknown[])?.[0]),
series: makeScalarCandidate(s, series?.Name),
issueNumber: makeScalarCandidate(s, raw.Number),
collectionTitle: makeScalarCandidate(s, raw.CollectionTitle),
publisher: makeScalarCandidate(s, pub?.Name),
imprint: makeScalarCandidate(s, pub?.Imprint),
coverDate: makeScalarCandidate(s, raw.CoverDate),
storeDate: makeScalarCandidate(s, raw.StoreDate),
description: makeScalarCandidate(s, raw.Summary),
notes: makeScalarCandidate(s, raw.Notes),
ageRating: makeScalarCandidate(s, raw.AgeRating),
pageCount: makeScalarCandidate(s, raw.PageCount),
format: makeScalarCandidate(s, series?.Format),
language: makeScalarCandidate(s, series?.lang),
genres: nameList((raw.Genres as unknown[]) ?? []),
tags: ((raw.Tags as unknown[]) ?? [])
.filter((t) => !isNil(t))
.map((t) => makeArrayItem(s, t, safeString(t) ?? "")),
characters: nameList((raw.Characters as unknown[]) ?? []),
teams: nameList((raw.Teams as unknown[]) ?? []),
locations: nameList((raw.Locations as unknown[]) ?? []),
universes: ((raw.Universes as unknown[]) ?? [])
.filter((u): u is Record<string, unknown> => !isNil(u))
.map((u) =>
makeArrayItem(
s,
u,
[u.Name, u.Designation].filter(Boolean).join(" — "),
),
),
storyArcs: ((raw.Arcs as unknown[]) ?? [])
.filter((a): a is Record<string, unknown> => !isNil(a))
.map((a) =>
makeArrayItem(
s,
a,
[a.Name, a.Number ? `#${a.Number}` : null].filter(Boolean).join(" "),
),
),
stories: ((raw.Stories as unknown[]) ?? [])
.filter((t) => !isNil(t))
.map((t) => makeArrayItem(s, t, safeString(t) ?? "")),
creators: ((raw.Credits as unknown[]) ?? [])
.filter((c): c is Record<string, unknown> => !isNil(c))
.flatMap((c) => {
const creator = c.Creator as Record<string, unknown> | undefined;
const roles = (c.Roles as unknown[]) ?? [];
return roles
.filter((r): r is Record<string, unknown> => !isNil(r))
.map((r) =>
makeCreditItem(
s,
safeString(creator?.name) ?? "",
safeString(r.name ?? r) ?? "",
safeString(creator?.id) ?? undefined,
),
);
}),
reprints: ((raw.Reprints as unknown[]) ?? [])
.filter((r) => !isNil(r))
.map((r) => makeArrayItem(s, r, safeString(r) ?? "")),
urls: ((raw.URLs as unknown[]) ?? [])
.filter((u) => !isNil(u))
.map((u) => makeArrayItem(s, u, safeString(u) ?? "")),
};
}
/**
* Extract canonical fields from a ComicInfo.xml payload.
* Values are xml2js-parsed with `normalizeTags` (each key wraps its value in a single-element array).
* Genre is a comma-separated string; the web URL maps to `urls`.
*/
function fromComicInfo(ci: Record<string, unknown>): AdapterResult {
const s: SourceKey = "comicInfo";
const webUrl = xmlVal(ci, "web");
const genreItems: ArrayItem[] = (xmlVal(ci, "genre") ?? "")
.split(",")
.map((g) => g.trim())
.filter(Boolean)
.map((g) => makeArrayItem(s, g, g));
return {
series: makeScalarCandidate(s, xmlVal(ci, "series")),
issueNumber: makeScalarCandidate(s, xmlVal(ci, "number")),
publisher: makeScalarCandidate(s, xmlVal(ci, "publisher")),
description: makeScalarCandidate(s, xmlVal(ci, "summary")),
notes: makeScalarCandidate(s, xmlVal(ci, "notes")),
pageCount: makeScalarCandidate(s, xmlVal(ci, "pagecount")),
language: makeScalarCandidate(s, xmlVal(ci, "languageiso")),
urls: webUrl ? [makeArrayItem(s, webUrl, webUrl)] : [],
genres: genreItems,
};
}
/** GCD free-text credit fields: field key → role name. */
const GCD_CREDIT_FIELDS: Array<{ key: string; role: string }> = [
{ key: "script", role: "Writer" },
{ key: "pencils", role: "Penciller" },
{ key: "inks", role: "Inker" },
{ key: "colors", role: "Colorist" },
{ key: "letters", role: "Letterer" },
{ key: "editing", role: "Editor" },
];
/** Split a GCD free-text credit string (semicolon-separated; strips bracketed annotations). */
function splitGCDCreditString(raw: string): string[] {
return raw
.split(/;/)
.map((name) => name.replace(/\[.*?\]/g, "").trim())
.filter(Boolean);
}
/** Parse a GCD price string like "0.10 USD" or "10p". Returns null on failure. */
function parseGCDPrice(
raw: string,
): { amount: number; currency: string } | null {
const match = raw.trim().match(/^([\d.,]+)\s*([A-Z]{2,3}|p|¢|€|£|\$)?/);
if (!match) return null;
const amount = parseFloat(match[1].replace(",", "."));
const currency = match[2] ?? "USD";
if (isNaN(amount)) return null;
return { amount, currency };
}
function fromGCD(raw: Record<string, unknown>): AdapterResult {
const s: SourceKey = "gcd";
const series = raw.series as Record<string, unknown> | undefined;
const language = series?.language as Record<string, unknown> | undefined;
const publisher = series?.publisher as Record<string, unknown> | undefined;
const indiciaPublisher = raw.indicia_publisher as
| Record<string, unknown>
| undefined;
const stories = (raw.stories as Record<string, unknown>[]) ?? [];
const primaryStory = stories[0] ?? {};
const creditItems: CreditItem[] = [];
if (raw.editing) {
splitGCDCreditString(String(raw.editing)).forEach((name) =>
creditItems.push(makeCreditItem(s, name, "Editor")),
);
}
GCD_CREDIT_FIELDS.forEach(({ key, role }) => {
const val = safeString(primaryStory[key]);
if (!val) return;
splitGCDCreditString(val).forEach((name) =>
creditItems.push(makeCreditItem(s, name, role)),
);
});
const genreItems: ArrayItem[] = (safeString(primaryStory.genre) ?? "")
.split(",")
.map((g) => g.trim())
.filter(Boolean)
.map((g) => makeArrayItem(s, g, g));
const characterItems: ArrayItem[] = (
safeString(primaryStory.characters) ?? ""
)
.split(/[;,]/)
.map((c) => c.trim())
.filter(Boolean)
.map((c) => makeArrayItem(s, c, c));
const storyTitles: ArrayItem[] = stories
.map((st) => safeString(st.title))
.filter((t): t is string => Boolean(t))
.map((t) => makeArrayItem(s, t, t));
const priceItems: ArrayItem[] = [];
const priceStr = safeString(raw.price);
if (priceStr) {
const parsed = parseGCDPrice(priceStr);
if (parsed) {
priceItems.push(makeArrayItem(s, { ...parsed, country: "US" }, priceStr));
}
}
return {
series: makeScalarCandidate(s, series?.name),
issueNumber: makeScalarCandidate(s, raw.number),
title: makeScalarCandidate(s, raw.title ?? primaryStory.title),
volume: makeScalarCandidate(s, raw.volume),
// Prefer indicia publisher (as-printed) over series publisher
publisher: makeScalarCandidate(s, indiciaPublisher?.name ?? publisher?.name),
coverDate: makeScalarCandidate(s, raw.publication_date),
storeDate: makeScalarCandidate(s, raw.on_sale_date ?? raw.key_date),
pageCount: makeScalarCandidate(s, raw.page_count),
notes: makeScalarCandidate(s, raw.notes),
language: makeScalarCandidate(s, language?.code),
ageRating: makeScalarCandidate(s, raw.rating),
genres: genreItems,
characters: characterItems,
stories: storyTitles,
creators: creditItems,
prices: priceItems,
};
}
function fromLocg(locg: Record<string, unknown>): AdapterResult {
const s: SourceKey = "locg";
return {
title: makeScalarCandidate(s, locg.name),
publisher: makeScalarCandidate(s, locg.publisher),
description: makeScalarCandidate(s, locg.description),
coverImage: makeScalarCandidate(s, locg.cover),
communityRating: makeScalarCandidate(s, locg.rating),
publicationDate: makeScalarCandidate(s, locg.publicationDate),
};
}
function fromInferred(inf: RawInferredMetadata["issue"]): AdapterResult {
if (!inf) return {};
const s: SourceKey = "inferredMetadata";
return {
title: makeScalarCandidate(s, inf.name),
issueNumber: makeScalarCandidate(s, inf.number),
volume: makeScalarCandidate(s, inf.year),
};
}
// ── State building ─────────────────────────────────────────────────────────────
/**
* Merge all adapter results directly into a `ReconcilerState`.
* Array and credit items are deduplicated by `itemKey` using a Set (O(n)).
* Scalar conflicts are auto-resolved when all sources agree on the same value.
*/
function buildState(
sources: Partial<Record<SourceKey, AdapterResult>>,
): ReconcilerState {
const state: ReconcilerState = {};
const scalarMap: Record<string, ScalarCandidate[]> = {};
for (const adapterResult of Object.values(sources)) {
if (!adapterResult) continue;
for (const [field, value] of Object.entries(adapterResult)) {
if (!value) continue;
if (Array.isArray(value)) {
// Presence of `role` distinguishes CreditItem[] from ArrayItem[].
const isCredits = value.length > 0 && "role" in value[0];
if (isCredits) {
const prev = state[field];
const existing: CreditItem[] =
prev?.kind === "credits" ? prev.items : [];
const seen = new Set(existing.map((i) => i.itemKey));
const merged = [...existing];
for (const item of value as CreditItem[]) {
if (!seen.has(item.itemKey)) {
seen.add(item.itemKey);
merged.push(item);
}
}
state[field] = { kind: "credits", items: merged };
} else {
const prev = state[field];
const existing: ArrayItem[] =
prev?.kind === "array" ? prev.items : [];
const seen = new Set(existing.map((i) => i.itemKey));
const merged = [...existing];
for (const item of value as ArrayItem[]) {
if (!seen.has(item.itemKey)) {
seen.add(item.itemKey);
merged.push(item);
}
}
state[field] = { kind: "array", items: merged };
}
} else {
(scalarMap[field] ??= []).push(value as ScalarCandidate);
}
}
}
for (const [field, candidates] of Object.entries(scalarMap)) {
const allAgree =
candidates.length === 1 ||
candidates.every((c) => c.value === candidates[0].value);
state[field] = {
kind: "scalar",
candidates,
selectedSource: allAgree ? candidates[0].source : null,
};
}
return state;
}
// ── Reducer ────────────────────────────────────────────────────────────────────
type Action =
| { type: "SELECT_SCALAR"; field: string; source: SourceKey }
| { type: "SET_USER_VALUE"; field: string; value: string }
| { type: "TOGGLE_ITEM"; field: string; itemKey: string; selected: boolean }
| { type: "SET_BASE_SOURCE"; source: SourceKey }
| { type: "RESET"; initial: ReconcilerState };
function reducer(state: ReconcilerState, action: Action): ReconcilerState {
switch (action.type) {
case "SELECT_SCALAR": {
const field = state[action.field];
if (field?.kind !== "scalar") return state;
return {
...state,
[action.field]: {
...field,
selectedSource: action.source,
userValue: undefined,
},
};
}
case "SET_USER_VALUE": {
const field = state[action.field];
if (field?.kind !== "scalar") return state;
return {
...state,
[action.field]: {
...field,
selectedSource: null,
userValue: action.value,
},
};
}
case "TOGGLE_ITEM": {
const field = state[action.field];
if (field?.kind === "array" || field?.kind === "credits") {
return {
...state,
[action.field]: {
...field,
items: field.items.map((item) =>
item.itemKey === action.itemKey
? { ...item, selected: action.selected }
: item,
),
} as FieldState,
};
}
return state;
}
case "SET_BASE_SOURCE": {
const next = { ...state };
for (const [field, fieldState] of Object.entries(next)) {
if (fieldState.kind !== "scalar") continue;
if (fieldState.candidates.some((c) => c.source === action.source)) {
next[field] = {
...fieldState,
selectedSource: action.source,
userValue: undefined,
};
}
}
return next;
}
case "RESET":
return action.initial;
default:
return state;
}
}
// ── Canonical record ───────────────────────────────────────────────────────────
export interface CanonicalFieldValue {
value: unknown;
source: SourceKey | "user";
}
export type CanonicalRecord = Partial<Record<string, CanonicalFieldValue>>;
function deriveCanonicalRecord(state: ReconcilerState): CanonicalRecord {
const record: CanonicalRecord = {};
for (const [field, fieldState] of Object.entries(state)) {
if (fieldState.kind === "scalar") {
if (fieldState.userValue !== undefined) {
record[field] = { value: fieldState.userValue, source: "user" };
} else if (fieldState.selectedSource !== null) {
const candidate = fieldState.candidates.find(
(c) => c.source === fieldState.selectedSource,
);
if (candidate) {
record[field] = { value: candidate.value, source: candidate.source };
}
}
} else if (fieldState.kind === "array") {
const selected = fieldState.items.filter((i) => i.selected);
if (selected.length > 0) {
const counts = selected.reduce<Record<string, number>>((acc, i) => {
acc[i.source] = (acc[i.source] ?? 0) + 1;
return acc;
}, {});
const dominant = Object.entries(counts).sort(
([, a], [, b]) => b - a,
)[0][0] as SourceKey;
record[field] = {
value: selected.map((i) => i.rawValue),
source: dominant,
};
}
} else if (fieldState.kind === "credits") {
const selected = fieldState.items.filter((i) => i.selected);
if (selected.length > 0) {
record[field] = { value: selected, source: selected[0].source };
}
}
}
return record;
}
// ── Hook ───────────────────────────────────────────────────────────────────────
export interface UseReconcilerResult {
state: ReconcilerState;
/** Number of scalar fields with a conflict that has no selection yet. */
unresolvedCount: number;
/** True if any field has candidates from more than one source. */
hasConflicts: boolean;
canonicalRecord: CanonicalRecord;
selectScalar: (field: string, source: SourceKey) => void;
/** Override a scalar field with a user-typed value. */
setUserValue: (field: string, value: string) => void;
toggleItem: (field: string, itemKey: string, selected: boolean) => void;
/** Adopt all available fields from a single source. */
setBaseSource: (source: SourceKey) => void;
reset: () => void;
}
export function useReconciler(
sourcedMetadata: RawSourcedMetadata,
inferredMetadata?: RawInferredMetadata,
): UseReconcilerResult {
const initial = useMemo(() => {
const adapters: Partial<Record<SourceKey, AdapterResult>> = {};
if (!isEmpty(sourcedMetadata.comicvine)) {
adapters.comicvine = fromComicVine(
sourcedMetadata.comicvine as Record<string, unknown>,
);
}
const metron = ensureParsed(sourcedMetadata.metron);
if (metron) adapters.metron = fromMetron(metron);
const gcd = ensureParsed(sourcedMetadata.gcd);
if (gcd) adapters.gcd = fromGCD(gcd);
if (!isEmpty(sourcedMetadata.locg)) {
adapters.locg = fromLocg(
sourcedMetadata.locg as Record<string, unknown>,
);
}
const ci = ensureParsed(sourcedMetadata.comicInfo);
if (ci) adapters.comicInfo = fromComicInfo(ci);
if (inferredMetadata?.issue) {
adapters.inferredMetadata = fromInferred(inferredMetadata.issue);
}
return buildState(adapters);
}, [sourcedMetadata, inferredMetadata]);
const [state, dispatch] = useReducer(reducer, initial);
const unresolvedCount = useMemo(
() =>
Object.values(state).filter(
(f) =>
f.kind === "scalar" &&
f.selectedSource === null &&
f.userValue === undefined &&
f.candidates.length > 1,
).length,
[state],
);
const hasConflicts = useMemo(
() =>
Object.values(state).some(
(f) =>
(f.kind === "scalar" && f.candidates.length > 1) ||
((f.kind === "array" || f.kind === "credits") &&
new Set(
(f.items as Array<ArrayItem | CreditItem>).map((i) => i.source),
).size > 1),
),
[state],
);
const canonicalRecord = useMemo(() => deriveCanonicalRecord(state), [state]);
return {
state,
unresolvedCount,
hasConflicts,
canonicalRecord,
selectScalar: (field, source) =>
dispatch({ type: "SELECT_SCALAR", field, source }),
setUserValue: (field, value) =>
dispatch({ type: "SET_USER_VALUE", field, value }),
toggleItem: (field, itemKey, selected) =>
dispatch({ type: "TOGGLE_ITEM", field, itemKey, selected }),
setBaseSource: (source) =>
dispatch({ type: "SET_BASE_SOURCE", source }),
reset: () => dispatch({ type: "RESET", initial }),
};
}

View File

@@ -29,6 +29,7 @@ export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactEleme
return (
<div className="mt-5">
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Your Library In Numbers"
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}

View File

@@ -92,6 +92,7 @@ export const PullList = (): ReactElement => {
return (
<>
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Discover"
subHeaderContent={

View File

@@ -27,6 +27,7 @@ export const RecentlyImported = (
return (
<div>
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."

View File

@@ -31,6 +31,7 @@ export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
return (
<div>
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Volumes"
subHeaderContent={<>Based on ComicVine Volume information</>}

View File

@@ -30,6 +30,7 @@ export const WantedComicsList = ({
return (
<div>
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Wanted Comics"
subHeaderContent={<>Comics marked as wanted from various sources</>}

View File

@@ -47,6 +47,7 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
onChange={(e) => performSearch(e)}
/>
{/* TODO: Switch to Solar icon */}
<span className="icon is-right mt-2">
<i className="fa-solid fa-magnifying-glass"></i>
</span>

View File

@@ -79,6 +79,7 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
/>
</span>
)}
{/* TODO: Switch to Solar icon */}
{isNil(rawFileDetails) && (
<span className="icon has-text-info">
<i className="fas fa-adjust" />

View File

@@ -129,6 +129,7 @@ const VolumeDetails = (props): ReactElement => {
<span className="tag is-warning mr-1">
{issue.issue_number}
</span>
{/* TODO: Switch to Solar icon */}
{!isEmpty(issue.matches) ? (
<>
<span className="icon has-text-success">

View File

@@ -66,6 +66,7 @@ export const DnD = (data) => {
>
<div className="box p-2 control-palette">
<span className="tag is-warning mr-2">{index}</span>
{/* TODO: Switch to Solar icons */}
<span className="icon is-small mr-2">
<i className="fa-solid fa-vial"></i>
</span>

View File

@@ -93,23 +93,37 @@ export type CanonicalMetadata = {
__typename?: 'CanonicalMetadata';
ageRating?: Maybe<MetadataField>;
characters?: Maybe<Array<MetadataField>>;
collectionTitle?: Maybe<MetadataField>;
communityRating?: Maybe<MetadataField>;
coverDate?: Maybe<MetadataField>;
coverImage?: Maybe<MetadataField>;
creators?: Maybe<Array<Creator>>;
description?: Maybe<MetadataField>;
externalIDs?: Maybe<Array<ExternalId>>;
format?: Maybe<MetadataField>;
genres?: Maybe<Array<MetadataField>>;
gtin?: Maybe<GtinField>;
imprint?: Maybe<MetadataField>;
issueNumber?: Maybe<MetadataField>;
language?: Maybe<MetadataField>;
lastModified?: Maybe<MetadataField>;
locations?: Maybe<Array<MetadataField>>;
notes?: Maybe<MetadataField>;
pageCount?: Maybe<MetadataField>;
prices?: Maybe<Array<PriceField>>;
publicationDate?: Maybe<MetadataField>;
publisher?: Maybe<MetadataField>;
reprints?: Maybe<Array<ReprintField>>;
series?: Maybe<MetadataField>;
storyArcs?: Maybe<Array<MetadataField>>;
seriesInfo?: Maybe<SeriesInfo>;
storeDate?: Maybe<MetadataField>;
stories?: Maybe<Array<MetadataField>>;
storyArcs?: Maybe<Array<StoryArcField>>;
tags?: Maybe<Array<MetadataField>>;
teams?: Maybe<Array<MetadataField>>;
title?: Maybe<MetadataField>;
universes?: Maybe<Array<UniverseField>>;
urls?: Maybe<Array<UrlField>>;
volume?: Maybe<MetadataField>;
};
@@ -226,6 +240,7 @@ export type CoverInput = {
export type Creator = {
__typename?: 'Creator';
id?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
provenance: Provenance;
role: Scalars['String']['output'];
@@ -261,6 +276,14 @@ export type DirectorySize = {
totalSizeInMB: Scalars['Float']['output'];
};
export type ExternalId = {
__typename?: 'ExternalID';
externalId: Scalars['String']['output'];
primary?: Maybe<Scalars['Boolean']['output']>;
provenance: Provenance;
source: MetadataSource;
};
export type FieldOverride = {
__typename?: 'FieldOverride';
field: Scalars['String']['output'];
@@ -295,6 +318,14 @@ export type ForceCompleteResult = {
success: Scalars['Boolean']['output'];
};
export type GtinField = {
__typename?: 'GTINField';
isbn?: Maybe<Scalars['String']['output']>;
provenance: Provenance;
upc?: Maybe<Scalars['String']['output']>;
userOverride?: Maybe<Scalars['Boolean']['output']>;
};
export type GetResourceInput = {
fieldList?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<Scalars['String']['input']>;
@@ -544,13 +575,6 @@ export type MatchedResult = {
score?: Maybe<Scalars['String']['output']>;
};
export type MetadataArrayField = {
__typename?: 'MetadataArrayField';
provenance: Provenance;
userOverride?: Maybe<Scalars['Boolean']['output']>;
values: Array<Scalars['String']['output']>;
};
export type MetadataConflict = {
__typename?: 'MetadataConflict';
candidates: Array<MetadataField>;
@@ -752,6 +776,14 @@ export type PersonCredit = {
site_detail_url?: Maybe<Scalars['String']['output']>;
};
export type PriceField = {
__typename?: 'PriceField';
amount: Scalars['Float']['output'];
country: Scalars['String']['output'];
currency?: Maybe<Scalars['String']['output']>;
provenance: Provenance;
};
export type Provenance = {
__typename?: 'Provenance';
confidence: Scalars['Float']['output'];
@@ -969,6 +1001,13 @@ export type RawFileDetailsInput = {
pageCount?: InputMaybe<Scalars['Int']['input']>;
};
export type ReprintField = {
__typename?: 'ReprintField';
description: Scalars['String']['output'];
id?: Maybe<Scalars['String']['output']>;
provenance: Provenance;
};
export type ScorerConfigurationInput = {
searchParams?: InputMaybe<SearchParamsInput>;
};
@@ -1052,6 +1091,17 @@ export enum SearchType {
Wanted = 'wanted'
}
export type SeriesInfo = {
__typename?: 'SeriesInfo';
alternativeNames?: Maybe<Array<MetadataField>>;
issueCount?: Maybe<Scalars['Int']['output']>;
language?: Maybe<Scalars['String']['output']>;
provenance: Provenance;
sortName?: Maybe<Scalars['String']['output']>;
startYear?: Maybe<Scalars['Int']['output']>;
volumeCount?: Maybe<Scalars['Int']['output']>;
};
export type SourcePriority = {
__typename?: 'SourcePriority';
enabled: Scalars['Boolean']['output'];
@@ -1114,6 +1164,14 @@ export type StoryArcCredit = {
site_detail_url?: Maybe<Scalars['String']['output']>;
};
export type StoryArcField = {
__typename?: 'StoryArcField';
id?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
number?: Maybe<Scalars['Int']['output']>;
provenance: Provenance;
};
export type TeamCredit = {
__typename?: 'TeamCredit';
api_detail_url?: Maybe<Scalars['String']['output']>;
@@ -1140,6 +1198,21 @@ export type TorrentSearchResult = {
title?: Maybe<Scalars['String']['output']>;
};
export type UrlField = {
__typename?: 'URLField';
primary?: Maybe<Scalars['Boolean']['output']>;
provenance: Provenance;
url: Scalars['String']['output'];
};
export type UniverseField = {
__typename?: 'UniverseField';
designation?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
provenance: Provenance;
};
export type UserPreferences = {
__typename?: 'UserPreferences';
autoMerge: AutoMergeSettings;

View File

@@ -1,7 +1,21 @@
fragment ProvenanceFull on Provenance {
source
sourceId
confidence
fetchedAt
url
}
fragment MetadataFieldFull on MetadataField {
value
provenance { ...ProvenanceFull }
userOverride
}
query GetComicById($id: ID!) {
comic(id: $id) {
id
# Inferred metadata
inferredMetadata {
issue {
@@ -11,132 +25,106 @@ query GetComicById($id: ID!) {
subtitle
}
}
# Canonical metadata
canonicalMetadata {
title {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
# ── Identity ──────────────────────────────────────────────────────────
title { ...MetadataFieldFull }
series { ...MetadataFieldFull }
volume { ...MetadataFieldFull }
issueNumber { ...MetadataFieldFull }
collectionTitle { ...MetadataFieldFull }
# ── Series ────────────────────────────────────────────────────────────
seriesInfo {
issueCount
startYear
volumeCount
sortName
language
alternativeNames { ...MetadataFieldFull }
provenance { ...ProvenanceFull }
}
series {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
# ── Publication ───────────────────────────────────────────────────────
publisher { ...MetadataFieldFull }
imprint { ...MetadataFieldFull }
coverDate { ...MetadataFieldFull }
storeDate { ...MetadataFieldFull }
publicationDate { ...MetadataFieldFull }
language { ...MetadataFieldFull }
# ── Content ───────────────────────────────────────────────────────────
description { ...MetadataFieldFull }
notes { ...MetadataFieldFull }
stories { ...MetadataFieldFull }
storyArcs {
name
number
id
provenance { ...ProvenanceFull }
}
volume {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
issueNumber {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
publisher {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
publicationDate {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
coverDate {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
description {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
characters { ...MetadataFieldFull }
teams { ...MetadataFieldFull }
locations { ...MetadataFieldFull }
universes {
name
designation
id
provenance { ...ProvenanceFull }
}
coverImage { ...MetadataFieldFull }
# ── Credits ───────────────────────────────────────────────────────────
creators {
name
role
provenance {
source
sourceId
confidence
fetchedAt
url
}
provenance { ...ProvenanceFull }
}
pageCount {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
# ── Classification ────────────────────────────────────────────────────
genres { ...MetadataFieldFull }
tags { ...MetadataFieldFull }
ageRating { ...MetadataFieldFull }
# ── Physical ──────────────────────────────────────────────────────────
pageCount { ...MetadataFieldFull }
format { ...MetadataFieldFull }
# ── Commercial ────────────────────────────────────────────────────────
prices {
amount
currency
country
provenance { ...ProvenanceFull }
}
coverImage {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
gtin {
isbn
upc
userOverride
provenance { ...ProvenanceFull }
}
reprints {
description
id
provenance { ...ProvenanceFull }
}
communityRating { ...MetadataFieldFull }
# ── External ──────────────────────────────────────────────────────────
externalIDs {
source
externalId
primary
provenance { ...ProvenanceFull }
}
urls {
url
primary
provenance { ...ProvenanceFull }
}
}
# Sourced metadata
sourcedMetadata {
comicInfo
@@ -155,7 +143,7 @@ query GetComicById($id: ID!) {
potw
}
}
# Raw file details
rawFileDetails {
name
@@ -174,7 +162,7 @@ query GetComicById($id: ID!) {
stats
}
}
# Import status
importStatus {
isImported
@@ -183,7 +171,7 @@ query GetComicById($id: ID!) {
score
}
}
# Timestamps
createdAt
updatedAt

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MetadataPanel } from '../components/shared/MetadataPanel';
import "../assets/scss/App.css";
import "../assets/scss/App.scss";
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading

View File

@@ -6,6 +6,8 @@ declare module "*.png" {
declare module "*.jpg";
declare module "*.gif";
declare module "*.less";
declare module "*.scss";
declare module "*.css";
// Comic types are now generated from GraphQL schema
// Import from '../../graphql/generated' instead

View File

@@ -1,7 +1,7 @@
import { addDynamicIconSelectors } from "@iconify/tailwind";
import type { Config } from "tailwindcss";
/** @type {import('tailwindcss').Config} */
module.exports = {
const config: Config = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
@@ -43,3 +43,5 @@ module.exports = {
plugins: [addDynamicIconSelectors()],
};
export default config;

View File

@@ -6,12 +6,12 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"module": "nodenext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,

View File

@@ -1,5 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
publicDir: "public",
@@ -26,7 +27,6 @@ export default defineConfig({
"date-fns",
"dayjs",
"axios",
"rxjs",
"socket.io-client",
"i18next",
"react-i18next",
@@ -45,6 +45,7 @@ export default defineConfig({
},
server: { host: true },
plugins: [
tailwindcss(),
react({
// Use React plugin in all *.jsx and *.tsx files
include: "**/*.{jsx,tsx}",

203
yarn.lock
View File

@@ -3131,51 +3131,109 @@
source-map-js "^1.2.1"
tailwindcss "4.2.1"
"@tailwindcss/node@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/node/-/node-4.2.2.tgz#840e904226dc1b379609de8a72323fc211568993"
integrity sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==
dependencies:
"@jridgewell/remapping" "^2.3.5"
enhanced-resolve "^5.19.0"
jiti "^2.6.1"
lightningcss "1.32.0"
magic-string "^0.30.21"
source-map-js "^1.2.1"
tailwindcss "4.2.2"
"@tailwindcss/oxide-android-arm64@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz#a7c24919b607e7f884e6ab97799d12c7fb5b47bd"
integrity sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==
"@tailwindcss/oxide-android-arm64@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz#61d9ec5c18394fe7a972e99e19e6065e833da77c"
integrity sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==
"@tailwindcss/oxide-darwin-arm64@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz#6f6e91ff0e1b5476cc0dad0da1ea8474f4563212"
integrity sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==
"@tailwindcss/oxide-darwin-arm64@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz#9ad7b141789dae235c85d2f7874592bf869f636e"
integrity sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==
"@tailwindcss/oxide-darwin-x64@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz#1e59ef0665f6cb9e658bf0ebcb3cb50f21b2c175"
integrity sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==
"@tailwindcss/oxide-darwin-x64@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz#a5899f1fbe55c4eddcbc871b835d5183ba34658c"
integrity sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==
"@tailwindcss/oxide-freebsd-x64@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz#6b0c75e9dac7f1a241cb9a5eaa89f0d9664835b6"
integrity sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==
"@tailwindcss/oxide-freebsd-x64@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz#76185bb1bea9af915a5b9f465323861646587e21"
integrity sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz#717044d8fe746b1f0760485946c0c9a900174f7b"
integrity sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz#74c17c69b2015f7600d566ab0990aaac8701128e"
integrity sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==
"@tailwindcss/oxide-linux-arm64-gnu@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz#f544b0faf166d80791347911b2dd4372a893129d"
integrity sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==
"@tailwindcss/oxide-linux-arm64-gnu@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz#38a846d9d5795bc3b57951172044d8dbb3c79aa6"
integrity sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==
"@tailwindcss/oxide-linux-arm64-musl@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz#9fbaf8dc00b858a2b955526abb15d88f5678d1ef"
integrity sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==
"@tailwindcss/oxide-linux-arm64-musl@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz#f4cc4129c17d3f2bcb01efef4d7a2f381e5e3f53"
integrity sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==
"@tailwindcss/oxide-linux-x64-gnu@4.2.1":
version "4.2.1"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz"
integrity sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==
"@tailwindcss/oxide-linux-x64-gnu@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz#7c4a00b0829e12736bd72ec74e1c08205448cc2e"
integrity sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==
"@tailwindcss/oxide-linux-x64-musl@4.2.1":
version "4.2.1"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz"
integrity sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==
"@tailwindcss/oxide-linux-x64-musl@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz#711756d7bbe97e221fc041b63a4f385b85ba4321"
integrity sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==
"@tailwindcss/oxide-wasm32-wasi@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz#7401e35f881d3654b6180badd1243d75a2702ea5"
@@ -3188,16 +3246,38 @@
"@tybys/wasm-util" "^0.10.1"
tslib "^2.8.1"
"@tailwindcss/oxide-wasm32-wasi@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz#ed6d28567b7abb8505f824457c236d2cd07ee18e"
integrity sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==
dependencies:
"@emnapi/core" "^1.8.1"
"@emnapi/runtime" "^1.8.1"
"@emnapi/wasi-threads" "^1.1.0"
"@napi-rs/wasm-runtime" "^1.1.1"
"@tybys/wasm-util" "^0.10.1"
tslib "^2.8.1"
"@tailwindcss/oxide-win32-arm64-msvc@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz#63a502e7b696dcd976aa356b94ce0f4f8f832c44"
integrity sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==
"@tailwindcss/oxide-win32-arm64-msvc@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz#f2d0360e5bc06fe201537fb08193d3780e7dd24f"
integrity sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==
"@tailwindcss/oxide-win32-x64-msvc@4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz#8cc59b28ebc4dc866c0c14d7057f07f0ed04c4a8"
integrity sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==
"@tailwindcss/oxide-win32-x64-msvc@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz#10fc71b73883f9c3999b5b8c338fd96a45240dcb"
integrity sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==
"@tailwindcss/oxide@4.2.1":
version "4.2.1"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz"
@@ -3216,6 +3296,24 @@
"@tailwindcss/oxide-win32-arm64-msvc" "4.2.1"
"@tailwindcss/oxide-win32-x64-msvc" "4.2.1"
"@tailwindcss/oxide@4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz#c6534cb4b22650df605a58258235523a6abd7de8"
integrity sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==
optionalDependencies:
"@tailwindcss/oxide-android-arm64" "4.2.2"
"@tailwindcss/oxide-darwin-arm64" "4.2.2"
"@tailwindcss/oxide-darwin-x64" "4.2.2"
"@tailwindcss/oxide-freebsd-x64" "4.2.2"
"@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.2"
"@tailwindcss/oxide-linux-arm64-gnu" "4.2.2"
"@tailwindcss/oxide-linux-arm64-musl" "4.2.2"
"@tailwindcss/oxide-linux-x64-gnu" "4.2.2"
"@tailwindcss/oxide-linux-x64-musl" "4.2.2"
"@tailwindcss/oxide-wasm32-wasi" "4.2.2"
"@tailwindcss/oxide-win32-arm64-msvc" "4.2.2"
"@tailwindcss/oxide-win32-x64-msvc" "4.2.2"
"@tailwindcss/postcss@^4.2.1":
version "4.2.1"
resolved "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz"
@@ -3227,6 +3325,15 @@
postcss "^8.5.6"
tailwindcss "4.2.1"
"@tailwindcss/vite@^4.2.2":
version "4.2.2"
resolved "https://npm.apple.com/@tailwindcss/vite/-/vite-4.2.2.tgz#49240a41691c34b78ed4a80d07a39301f1a5129f"
integrity sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==
dependencies:
"@tailwindcss/node" "4.2.2"
"@tailwindcss/oxide" "4.2.2"
tailwindcss "4.2.2"
"@tanstack/eslint-plugin-query@^5.91.4":
version "5.91.4"
resolved "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz"
@@ -3514,12 +3621,12 @@
dependencies:
undici-types "~7.18.0"
"@types/node@^25.5.2":
version "25.5.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.2.tgz#94861e32f9ffd8de10b52bbec403465c84fff762"
integrity sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==
"@types/node@^25.6.0":
version "25.6.0"
resolved "https://npm.apple.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca"
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==
dependencies:
undici-types "~7.18.0"
undici-types "~7.19.0"
"@types/parse-json@^4.0.0":
version "4.0.2"
@@ -7790,56 +7897,111 @@ lightningcss-android-arm64@1.31.1:
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz#609ff48332adff452a8157a7c2842fd692a8eac4"
integrity sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==
lightningcss-android-arm64@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
lightningcss-darwin-arm64@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz#a13da040a7929582bab3ace9a67bdc146e99fc2d"
integrity sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==
lightningcss-darwin-arm64@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
lightningcss-darwin-x64@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz#f7482c311273571ec0c2bd8277c1f5f6e90e03a4"
integrity sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==
lightningcss-darwin-x64@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
lightningcss-freebsd-x64@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz#91df1bb290f1cb7bb2af832d7d0d8809225e0124"
integrity sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==
lightningcss-freebsd-x64@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
lightningcss-linux-arm-gnueabihf@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz#c3cad5ae8b70045f21600dc95295ab6166acf57e"
integrity sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==
lightningcss-linux-arm-gnueabihf@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
lightningcss-linux-arm64-gnu@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz#a5c4f6a5ac77447093f61b209c0bd7fef1f0a3e3"
integrity sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==
lightningcss-linux-arm64-gnu@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
lightningcss-linux-arm64-musl@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz#af26ab8f829b727ada0a200938a6c8796ff36900"
integrity sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==
lightningcss-linux-arm64-musl@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
lightningcss-linux-x64-gnu@1.31.1:
version "1.31.1"
resolved "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz"
integrity sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==
lightningcss-linux-x64-gnu@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
lightningcss-linux-x64-musl@1.31.1:
version "1.31.1"
resolved "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz"
integrity sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==
lightningcss-linux-x64-musl@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
lightningcss-win32-arm64-msvc@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz#79000fb8c57e94a91b8fc643e74d5a54407d7080"
integrity sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==
lightningcss-win32-arm64-msvc@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
lightningcss-win32-x64-msvc@1.31.1:
version "1.31.1"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz#7f025274c81c7d659829731e09c8b6f442209837"
integrity sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==
lightningcss-win32-x64-msvc@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
lightningcss@1.31.1:
version "1.31.1"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz"
@@ -7859,6 +8021,25 @@ lightningcss@1.31.1:
lightningcss-win32-arm64-msvc "1.31.1"
lightningcss-win32-x64-msvc "1.31.1"
lightningcss@1.32.0:
version "1.32.0"
resolved "https://npm.apple.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
lightningcss-android-arm64 "1.32.0"
lightningcss-darwin-arm64 "1.32.0"
lightningcss-darwin-x64 "1.32.0"
lightningcss-freebsd-x64 "1.32.0"
lightningcss-linux-arm-gnueabihf "1.32.0"
lightningcss-linux-arm64-gnu "1.32.0"
lightningcss-linux-arm64-musl "1.32.0"
lightningcss-linux-x64-gnu "1.32.0"
lightningcss-linux-x64-musl "1.32.0"
lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0"
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
@@ -10039,11 +10220,16 @@ tabbable@^6.0.0, tabbable@^6.4.0:
resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz"
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
tailwindcss@4.2.1, tailwindcss@^4.2.1:
tailwindcss@4.2.1:
version "4.2.1"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
integrity sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==
tailwindcss@4.2.2, tailwindcss@^4.2.2:
version "4.2.2"
resolved "https://npm.apple.com/tailwindcss/-/tailwindcss-4.2.2.tgz#688fb0751c8ca9044e890546510a2ee817308e87"
integrity sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==
tapable@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
@@ -10391,6 +10577,11 @@ undici-types@~7.18.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz"
integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==
undici-types@~7.19.0:
version "7.19.2"
resolved "https://npm.apple.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a"
integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz"