Compare commits

...

20 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
0949ebc637 📃 WIP bottom sheet 2026-04-03 10:55:10 -04:00
3e045f4c10 📃 Added a bottom sheet to Metadata info 2026-04-02 23:44:03 -04:00
17db1e64e1 🔨 Added JsDoc, WIP metadata reconciliation 2026-04-02 18:29:40 -04:00
d7ab553120 🔨 Clearning up the VolumeInformation tab 2026-04-02 13:07:13 -04:00
91592019c4 📗 Updated README 2026-03-26 20:55:23 -04:00
0e8f63101c 🤺 ComicVine matching in drawer UX fixed 2026-03-26 20:50:27 -04:00
4e2cad790b 🕶Added visibility guards to volumes, stats and wanted on dashboard 2026-03-09 23:11:15 -04:00
ba1b5bb965 🔨 Fix for showing accurate import counts 2026-03-09 22:54:47 -04:00
8546641152 🖌 Icon fixes 2026-03-09 22:31:59 -04:00
867935be39 📛 Added missing files to lib stats on Dashboard 2026-03-09 21:54:21 -04:00
d506cf8ba8 🔢 Fix for filesize on disk on Dashboard 2026-03-09 21:33:21 -04:00
71d7034d01 🎫 Fixed download badges in T2Table 2026-03-09 20:39:58 -04:00
a217d447fa 🔨 Added missing file statuses on Dashboard and Library 2026-03-09 19:55:16 -04:00
20336e5569 🔍 Missing files statuses in the UI 2026-03-09 17:10:18 -04:00
8913e9cd99 🔨 ComicDetail grqphQL refactor 2026-03-09 11:44:24 -04:00
c392333170 🛠 Missing files logic 2026-03-07 21:52:26 -05:00
56 changed files with 4457 additions and 20670 deletions

View File

@@ -0,0 +1,379 @@
---
name: jsdoc
description: Commenting and documentation guidelines. Auto-activate when the user discusses comments, documentation, docstrings, code clarity, API docs, JSDoc, or asks about commenting strategies.
---
Auto-activate when: User discusses comments, documentation, docstrings, code clarity, code quality, API docs, JSDoc, Python docstrings, or asks about commenting strategies.
Core Principle
Write code that speaks for itself. Comment only when necessary to explain WHY, not WHAT.
Most code does not need comments. Well-written code with clear naming and structure is self-documenting.
The best comment is the one you don't need to write because the code is already obvious.
The Commenting Philosophy
When to Comment
✅ DO comment when explaining:
WHY something is done (business logic, design decisions)
Complex algorithms and their reasoning
Non-obvious trade-offs or constraints
Workarounds for bugs or limitations
API contracts and public interfaces
Regex patterns and what they match
Performance considerations or optimizations
Constants and magic numbers
Gotchas or surprising behaviors
❌ DON'T comment when:
The code is obvious and self-explanatory
The comment repeats the code (redundant)
Better naming would eliminate the need
The comment would become outdated quickly
It's decorative or organizational noise
It states what a standard language construct does
Comment Anti-Patterns
❌ 1. Obvious Comments
BAD:
counter = 0 # Initialize counter to zero
counter += 1 # Increment counter by one
user_name = input("Enter name: ") # Get user name from input
Better: No comment needed - the code is self-explanatory.
❌ 2. Redundant Comments
BAD:
def get_user_name(user):
return user.name # Return the user's name
def calculate_total(items):
# Loop through items and sum the prices
total = 0
for item in items:
total += item.price
return total
Better:
def get_user_name(user):
return user.name
def calculate_total(items):
return sum(item.price for item in items)
❌ 3. Outdated Comments
BAD:
# Calculate tax at 5% rate
tax = price * 0.08 # Actually 8%, comment is wrong
# DEPRECATED: Use new_api_function() instead
def old_function(): # Still being used, comment is misleading
pass
Better: Keep comments in sync with code, or remove them entirely.
❌ 4. Noise Comments
BAD:
# Start of function
def calculate():
# Declare variable
result = 0
# Return result
return result
# End of function
Better: Remove all of these comments.
❌ 5. Dead Code & Changelog Comments
BAD:
# Don't comment out code - use version control
# def old_function():
# return "deprecated"
# Don't maintain history in comments
# Modified by John on 2023-01-15
# Fixed bug reported by Sarah on 2023-02-03
Better: Delete the code. Git has the history.
Good Comment Examples
✅ Complex Business Logic
# Apply progressive tax brackets: 10% up to $10k, 20% above
# This matches IRS publication 501 for 2024
def calculate_progressive_tax(income):
if income <= 10000:
return income * 0.10
else:
return 1000 + (income - 10000) * 0.20
✅ Non-obvious Algorithms
# Using Floyd-Warshall for all-pairs shortest paths
# because we need distances between all nodes.
# Time: O(n³), Space: O(n²)
for k in range(vertices):
for i in range(vertices):
for j in range(vertices):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
✅ Regex Patterns
# Match email format: username@domain.extension
# Allows letters, numbers, dots, hyphens in username
# Requires valid domain and 2+ char extension
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
✅ API Constraints or Gotchas
# GitHub API rate limit: 5000 requests/hour for authenticated users
# We implement exponential backoff to handle rate limiting
await rate_limiter.wait()
response = await fetch(github_api_url)
✅ Workarounds for Bugs
# HACK: Workaround for bug in library v2.1.0
# Remove after upgrading to v2.2.0
# See: https://github.com/library/issues/123
if library_version == "2.1.0":
apply_workaround()
Decision Framework
Before writing a comment, ask yourself:
Step 1: Is the code self-explanatory?
If YES → No comment needed
If NO → Continue to step 2
Step 2: Would a better variable/function name eliminate the need?
If YES → Refactor the code instead
If NO → Continue to step 3
Step 3: Does this explain WHY, not WHAT?
If explaining WHAT → Refactor code to be clearer
If explaining WHY → Good comment candidate
Step 4: Will this help future maintainers?
If YES → Write the comment
If NO → Skip it
Special Cases for Comments
Public APIs and Docstrings
Python Docstrings
def calculate_compound_interest(
principal: float,
rate: float,
time: int,
compound_frequency: int = 1
) -> float:
"""
Calculate compound interest using the standard formula.
Args:
principal: Initial amount invested
rate: Annual interest rate as decimal (e.g., 0.05 for 5%)
time: Time period in years
compound_frequency: Times per year interest compounds (default: 1)
Returns:
Final amount after compound interest
Raises:
ValueError: If any parameter is negative
Example:
>>> calculate_compound_interest(1000, 0.05, 10)
1628.89
"""
if principal < 0 or rate < 0 or time < 0:
raise ValueError("Parameters must be non-negative")
# Compound interest formula: A = P(1 + r/n)^(nt)
return principal * (1 + rate / compound_frequency) ** (compound_frequency * time)
JavaScript/TypeScript JSDoc
/**
* Fetch user data from the API.
*
* @param {string} userId - The unique user identifier
* @param {Object} options - Configuration options
* @param {boolean} options.includeProfile - Include profile data (default: true)
* @param {number} options.timeout - Request timeout in ms (default: 5000)
*
* @returns {Promise<User>} User object with requested fields
*
* @throws {Error} If userId is invalid or request fails
*
* @example
* const user = await fetchUser('123', { includeProfile: true });
*/
async function fetchUser(userId, options = {}) {
// Implementation
}
Constants and Configuration
# Based on network reliability studies (95th percentile)
MAX_RETRIES = 3
# AWS Lambda timeout is 15s, leaving 5s buffer for cleanup
API_TIMEOUT = 10000 # milliseconds
# Cache duration optimized for balance between freshness and load
# See: docs/performance-tuning.md
CACHE_TTL = 300 # 5 minutes
Annotations for TODOs and Warnings
# TODO: Replace with proper authentication after security review
# Issue: #456
def temporary_auth(user):
return True
# WARNING: This function modifies the original array instead of creating a copy
def sort_in_place(arr):
arr.sort()
return arr
# FIXME: Memory leak in production - investigate connection pooling
# Ticket: JIRA-789
def get_connection():
return create_connection()
# PERF: Consider caching this result if called frequently in hot path
def expensive_calculation(data):
return complex_algorithm(data)
# SECURITY: Validate input to prevent SQL injection before using in query
def build_query(user_input):
sanitized = escape_sql(user_input)
return f"SELECT * FROM users WHERE name = '{sanitized}'"
Common Annotation Keywords
TODO: - Work that needs to be done
FIXME: - Known bugs that need fixing
HACK: - Temporary workarounds
NOTE: - Important information or context
WARNING: - Critical information about usage
PERF: - Performance considerations
SECURITY: - Security-related notes
BUG: - Known bug documentation
REFACTOR: - Code that needs refactoring
DEPRECATED: - Soon-to-be-removed code
Refactoring Over Commenting
Instead of Commenting Complex Code...
BAD: Complex code with comment
# Check if user is admin or has special permissions
if user.role == "admin" or (user.permissions and "special" in user.permissions):
grant_access()
...Extract to Named Function
GOOD: Self-explanatory through naming
def user_has_admin_access(user):
return user.role == "admin" or has_special_permission(user)
def has_special_permission(user):
return user.permissions and "special" in user.permissions
if user_has_admin_access(user):
grant_access()
Language-Specific Examples
JavaScript
// Good: Explains WHY we debounce
// Debounce search to reduce API calls (500ms wait after last keystroke)
const debouncedSearch = debounce(searchAPI, 500);
// Bad: Obvious
let count = 0; // Initialize count to zero
count++; // Increment count
// Good: Explains algorithm choice
// Using Set for O(1) lookup instead of Array.includes() which is O(n)
const seen = new Set(ids);
Python
# Good: Explains the algorithm choice
# Using binary search because data is sorted and we need O(log n) performance
index = bisect.bisect_left(sorted_list, target)
# Bad: Redundant
def get_total(items):
return sum(items) # Return the sum of items
# Good: Explains why we're doing this
# Extract to separate function for type checking in mypy
def validate_user(user):
if not user or not user.id:
raise ValueError("Invalid user")
return user
TypeScript
// Good: Explains the type assertion
// TypeScript can't infer this is never null after the check
const element = document.getElementById('app') as HTMLElement;
// Bad: Obvious
const sum = a + b; // Add a and b
// Good: Explains non-obvious behavior
// spread operator creates shallow copy; use JSON for deep copy
const newConfig = { ...config };
Comment Quality Checklist
Before committing, ensure your comments:
Explain WHY, not WHAT
Are grammatically correct and clear
Will remain accurate as code evolves
Add genuine value to code understanding
Are placed appropriately (above the code they describe)
Use proper spelling and professional language
Follow team conventions for annotation keywords
Could not be replaced by better naming or structure
Are not obvious statements about language features
Reference tickets/issues when applicable
Summary
Priority order:
Clear code - Self-explanatory through naming and structure
Good comments - Explain WHY when necessary
Documentation - API docs, docstrings for public interfaces
No comments - Better than bad comments that lie or clutter
Remember: Comments are a failure to make the code self-explanatory. Use them sparingly and wisely.
Key Takeaways
Goal Approach
Reduce comments Improve naming, extract functions, simplify logic
Improve clarity Use self-explanatory code structure, clear variable names
Document APIs Use docstrings/JSDoc for public interfaces
Explain WHY Comment only business logic, algorithms, workarounds
Maintain accuracy Update comments when code changes, or remove them

View File

@@ -0,0 +1,353 @@
---
name: typescript
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
---
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
Naming Conventions
Type Convention Example
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
Variables, Parameters, Functions lowerCamelCase userName, processData
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
Type Parameters Single letter or UpperCamelCase T, ResponseType
Naming Principles
Descriptive names, avoid ambiguous abbreviations
Treat acronyms as words: loadHttpUrl not loadHTTPURL
No prefixes like opt_ for optional parameters
No trailing underscores for private properties
Single-letter variables only when scope is <10 lines
Variable Declarations
// Always use const by default
const users = getUsers();
// Use let only when reassignment is needed
let count = 0;
count++;
// Never use var
// var x = 1; // WRONG
// One variable per declaration
const a = 1;
const b = 2;
// const a = 1, b = 2; // WRONG
Types and Interfaces
Prefer Interfaces Over Type Aliases
// Good: interface for object shapes
interface User {
id: string;
name: string;
email?: string;
}
// Avoid: type alias for object shapes
type User = {
id: string;
name: string;
};
// Type aliases OK for unions, intersections, mapped types
type Status = 'active' | 'inactive';
type Combined = TypeA & TypeB;
Type Inference
Leverage inference for trivially inferred types:
// Good: inference is clear
const name = 'Alice';
const items = [1, 2, 3];
// Good: explicit for complex expressions
const result: ProcessedData = complexTransformation(input);
Array Types
// Simple types: use T[]
const numbers: number[];
const names: readonly string[];
// Multi-dimensional: use T[][]
const matrix: number[][];
// Complex types: use Array<T>
const handlers: Array<(event: Event) => void>;
Null and Undefined
// Prefer optional fields over union with undefined
interface Config {
timeout?: number; // Good
// timeout: number | undefined; // Avoid
}
// Type aliases must NOT include |null or |undefined
type UserId = string; // Good
// type UserId = string | null; // WRONG
// May use == for null comparison (catches both null and undefined)
if (value == null) {
// handles both null and undefined
}
Types to Avoid
// Avoid any - use unknown instead
function parse(input: unknown): Data { }
// Avoid {} - use unknown, Record<string, T>, or object
function process(obj: Record<string, unknown>): void { }
// Use lowercase primitives
let name: string; // Good
// let name: String; // WRONG
// Never use wrapper objects
// new String('hello') // WRONG
Classes
Structure
class UserService {
// Fields first, initialized where declared
private readonly cache = new Map<string, User>();
private lastAccess: Date | null = null;
// Constructor with parameter properties
constructor(
private readonly api: ApiClient,
private readonly logger: Logger,
) {}
// Methods separated by blank lines
async getUser(id: string): Promise<User> {
// ...
}
private validateId(id: string): boolean {
// ...
}
}
Visibility
class Example {
// private by default, only use public when needed externally
private internalState = 0;
// readonly for properties never reassigned after construction
readonly id: string;
// Never use #private syntax - use TypeScript visibility
// #field = 1; // WRONG
private field = 1; // Good
}
Avoid Arrow Functions as Properties
class Handler {
// Avoid: arrow function as property
// handleClick = () => { ... };
// Good: instance method
handleClick(): void {
// ...
}
}
// Bind at call site if needed
element.addEventListener('click', () => handler.handleClick());
Static Methods
Never use this in static methods
Call on defining class, not subclasses
Functions
Prefer Function Declarations
// Good: function declaration for named functions
function processData(input: Data): Result {
return transform(input);
}
// Arrow functions when type annotation needed
const handler: EventHandler = (event) => {
// ...
};
Arrow Function Bodies
// Concise body only when return value is used
const double = (x: number) => x * 2;
// Block body when return should be void
const log = (msg: string) => {
console.log(msg);
};
Parameters
// Use rest parameters, not arguments
function sum(...numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}
// Destructuring for multiple optional params
interface Options {
timeout?: number;
retries?: number;
}
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
// ...
}
// Never name a parameter 'arguments'
Imports and Exports
Always Use Named Exports
// Good: named exports
export function processData() { }
export class UserService { }
export interface Config { }
// Never use default exports
// export default class UserService { } // WRONG
Import Styles
// Module import for large APIs
import * as fs from 'fs';
// Named imports for frequently used symbols
import { readFile, writeFile } from 'fs/promises';
// Type-only imports when only used as types
import type { User, Config } from './types';
Module Organization
Use modules, never namespace Foo { }
Never use require() - use ES6 imports
Use relative imports within same project
Avoid excessive ../../../
Control Structures
Always Use Braces
// Good
if (condition) {
doSomething();
}
// Exception: single-line if
if (condition) return early;
Loops
// Prefer for...of for arrays
for (const item of items) {
process(item);
}
// Use Object methods with for...of for objects
for (const [key, value] of Object.entries(obj)) {
// ...
}
// Never use unfiltered for...in on arrays
Equality
// Always use === and !==
if (a === b) { }
// Exception: == null catches both null and undefined
if (value == null) { }
Switch Statements
switch (status) {
case Status.Active:
handleActive();
break;
case Status.Inactive:
handleInactive();
break;
default:
// Always include default, even if empty
break;
}
Exception Handling
// Always throw Error instances
throw new Error('Something went wrong');
// throw 'error'; // WRONG
// Catch with unknown type
try {
riskyOperation();
} catch (e: unknown) {
if (e instanceof Error) {
logger.error(e.message);
}
throw e;
}
// Empty catch needs justification comment
try {
optional();
} catch {
// Intentionally ignored: fallback behavior handles this
}
Type Assertions
// Use 'as' syntax, not angle brackets
const input = value as string;
// const input = <string>value; // WRONG in TSX, avoid everywhere
// Double assertion through unknown when needed
const config = (rawData as unknown) as Config;
// Add comment explaining why assertion is safe
const element = document.getElementById('app') as HTMLElement;
// Safe: element exists in index.html
Strings
// Use single quotes for string literals
const name = 'Alice';
// Template literals for interpolation or multiline
const message = `Hello, ${name}!`;
const query = `
SELECT *
FROM users
WHERE id = ?
`;
// Never use backslash line continuations
Disallowed Features
Feature Alternative
var const or let
Array() constructor [] literal
Object() constructor {} literal
any type unknown
namespace modules
require() import
Default exports Named exports
#private fields private modifier
eval() Never use
const enum Regular enum
debugger Remove before commit
with Never use
Prototype modification Never modify

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)',

19401
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",
@@ -13,7 +14,8 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
"codegen:watch": "graphql-codegen --config codegen.yml --watch"
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
"knip": "knip"
},
"author": "Rishi Ghan",
"license": "MIT",
@@ -23,9 +25,9 @@
"@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",
"@tanstack/react-query": "^5.90.21",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
"@types/react-router-dom": "^5.3.3",
@@ -52,6 +54,7 @@
"immer": "^11.1.4",
"jsdoc": "^4.0.5",
"lodash": "^4.17.23",
"motion": "^12.38.0",
"pretty-bytes": "^7.1.0",
"prop-types": "^15.8.1",
"qs": "^6.15.0",
@@ -73,10 +76,10 @@
"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",
"vaul": "^1.1.2",
"vite": "^7.3.1",
"vite-plugin-html": "^3.2.2",
"websocket": "^1.0.35",
@@ -109,12 +112,13 @@
"@types/ellipsize": "^0.1.3",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.0",
"@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",
@@ -135,10 +139,10 @@
"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": "^5.9.3",
"typescript": "^6.0.2",
"wait-on": "^9.0.4"
},
"resolutions": {

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

@@ -7,11 +7,12 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
It is based on React 18, and uses:
1. _Redux_ for state management
1. _zustand_ for state management
2. _socket.io_ for transferring data in real-time
3. _React Router_ for routing
4. React DnD for drag-and-drop
5. @tanstack/react-table for all tables
6. @tanstack/react-query for API calls

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

@@ -1,4 +1,4 @@
import React, { useState, ReactElement, useCallback } from "react";
import React, { useState, ReactElement, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import Card from "../shared/Carda";
import { RawFileDetails } from "./RawFileDetails";
@@ -10,7 +10,7 @@ import "react-sliding-pane/dist/react-sliding-pane.css";
import SlidingPane from "react-sliding-pane";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { styled } from "styled-components";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
import type { RawFileDetails as RawFileDetailsType, InferredMetadata } from "../../graphql/generated";
// Extracted modules
import { useComicVineMatching } from "./useComicVineMatching";
@@ -23,57 +23,47 @@ const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
type InferredIssue = {
interface ComicVineMetadata {
name?: string;
number?: number;
year?: string;
subtitle?: string;
[key: string]: any;
};
volumeInformation?: Record<string, unknown>;
[key: string]: unknown;
}
type ComicVineMetadata = {
name?: string;
volumeInformation?: any;
[key: string]: any;
};
type Acquisition = {
interface Acquisition {
directconnect?: {
downloads?: any[];
downloads?: unknown[];
};
torrent?: any[];
[key: string]: any;
};
torrent?: unknown[];
[key: string]: unknown;
}
type ComicDetailProps = {
interface ComicDetailProps {
data: {
_id: string;
rawFileDetails?: RawFileDetailsType;
inferredMetadata: {
issue?: InferredIssue;
};
inferredMetadata: InferredMetadata;
sourcedMetadata: {
comicvine?: ComicVineMetadata;
locg?: any;
comicInfo?: any;
locg?: Record<string, unknown>;
comicInfo?: Record<string, unknown>;
};
acquisition?: Acquisition;
createdAt: string;
updatedAt: string;
};
userSettings?: any;
queryClient?: any;
userSettings?: Record<string, unknown>;
queryClient?: unknown;
comicObjectId?: string;
};
}
/**
* Component for displaying the metadata for a comic in greater detail.
* Displays full comic detail: cover, file info, action menu, and tabbed panels
* for metadata, archive operations, and acquisition.
*
* @component
* @example
* return (
* <ComicDetail/>
* )
* @param data.queryClient - react-query client passed through to the CV match
* panel so it can invalidate queries after a match is applied.
* @param data.comicObjectId - optional override for the comic ID; used when the
* component is rendered outside a route that provides the ID via `useParams`.
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
@@ -84,7 +74,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
updatedAt,
},
userSettings,
queryClient,
@@ -94,24 +83,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Modal handlers (currently unused but kept for future use)
const openModal = useCallback((filePath: string) => {
setIsOpen(true);
}, []);
const afterOpenModal = useCallback((things: any) => {
// Modal opened callback
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// Action event handlers
const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine);
@@ -124,7 +99,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
setVisible(true);
}, []);
// Action menu handler
// Hide "match on Comic Vine" when there are no raw file details — matching
// requires file metadata to seed the search query.
const Placeholder = components.Placeholder;
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
@@ -150,6 +126,11 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
const hasAnyMetadata =
isComicBookMetadataAvailable ||
!isEmpty(comicInfo) ||
!isNil(locg);
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
@@ -160,26 +141,29 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
});
// Query for airdc++
const airDCPPQuery = {
issue: {
name: issueName,
},
};
const airDCPPQuery = useMemo(() => ({
issue: { name: issueName },
}), [issueName]);
// Create tab configuration
const tabGroup = createTabConfig({
const openReconcilePanel = useCallback(() => {
setSlidingPanelContentId("metadataReconciliation");
setVisible(true);
}, []);
const tabGroup = useMemo(() => createTabConfig({
data: data.data,
comicInfo,
isComicBookMetadataAvailable,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId: _id,
userSettings,
issueName,
acquisition,
});
onReconcileMetadata: openReconcilePanel,
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
// Sliding panel content mapping
const renderSlidingPanelContent = () => {
@@ -190,6 +174,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
rawFileDetails={rawFileDetails}
inferredMetadata={inferredMetadata}
comicVineMatches={comicVineMatches}
// Prefer the route param; fall back to the data ID when rendered outside a route.
comicObjectId={comicObjectId || _id}
queryClient={queryClient}
onMatchApplied={() => {
@@ -224,10 +209,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<div className="grid">
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
rawFileDetails,
inferredMetadata,
createdAt,
}}
>
{/* action dropdown */}

View File

@@ -1,12 +1,21 @@
import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult";
import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props;
interface ComicVineMatchPanelProps {
props: {
comicObjectId: string;
comicVineMatches: any[];
queryClient?: any;
onMatchApplied?: () => void;
};
}
/** Displays ComicVine search results or a status message while searching. */
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
const { comicvine } = useStore(
useShallow((state) => ({
comicvine: state.comicvine,

View File

@@ -1,55 +1,41 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { Form, Field } from "react-final-form";
import React, { ReactElement } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
interface EditMetadataPanelProps {
data: {
name?: string | null;
[key: string]: any;
};
}
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Sliding panel form for manually editing comic metadata fields. */
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
const onSubmit = async () => {};
const { data } = props;
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
// const rawFileDetails = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
// );
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
mutators={{
...arrayMutators,
}}
mutators={{ ...arrayMutators }}
render={({
handleSubmit,
form: {
mutators: { push, pop },
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
},
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
<p className="text-xs">Do not enter the first zero</p>
</div>
<div>
{/* year */}
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
{/* page count */}
{/* Description */}
<div className="mt-2">
<label className="text-sm">Description</label>
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
/>
</div>
<hr size="1" />
<hr />
<div className="field is-horizontal">
<div className="field-label">
@@ -129,6 +112,7 @@ export const EditMetadataPanel = (props): ReactElement => {
className="input"
placeholder="SKU"
/>
{/* TODO: Switch to Solar icon */}
<span className="icon is-small is-left">
<i className="fa-solid fa-barcode"></i>
</span>
@@ -145,6 +129,7 @@ export const EditMetadataPanel = (props): ReactElement => {
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>
@@ -153,7 +138,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* Publisher */}
<div className="field is-horizontal">
@@ -167,6 +152,7 @@ export const EditMetadataPanel = (props): ReactElement => {
name={"publisher"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-print mr-2"></i> Publisher
</div>
@@ -190,6 +176,7 @@ export const EditMetadataPanel = (props): ReactElement => {
name={"story_arc"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-book-open mr-2"></i> Story Arc
</div>
@@ -213,6 +200,7 @@ export const EditMetadataPanel = (props): ReactElement => {
name={"series"}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fas fa-layer-group mr-2"></i> Series
</div>
@@ -224,7 +212,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* team credits */}
<div className="field is-horizontal">
@@ -267,6 +255,7 @@ export const EditMetadataPanel = (props): ReactElement => {
name={`${name}.creator`}
component={AsyncSelectPaginateAdapter}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fa-solid fa-ghost"></i> Creator
</div>
@@ -282,6 +271,7 @@ export const EditMetadataPanel = (props): ReactElement => {
name={`${name}.role`}
metronResource={"role"}
placeholder={
/* TODO: Switch to Solar icon */
<div>
<i className="fa-solid fa-key"></i> Role
</div>
@@ -290,6 +280,7 @@ export const EditMetadataPanel = (props): ReactElement => {
/>
</p>
</div>
{/* TODO: Switch to Solar icon */}
<span
className="icon is-danger mt-2"
onClick={() => fields.remove(index)}
@@ -302,7 +293,6 @@ export const EditMetadataPanel = (props): ReactElement => {
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>

View File

@@ -4,6 +4,7 @@ import { convert } from "html-to-text";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
import { useGetComicByIdQuery } from "../../graphql/generated";
interface MatchResultProps {
matchData: any;
@@ -31,7 +32,7 @@ export const MatchResult = (props: MatchResultProps) => {
// Invalidate and refetch the comic book metadata
if (props.queryClient) {
await props.queryClient.invalidateQueries({
queryKey: ["comicBookMetadata", comicObjectId],
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
});
}

View File

@@ -1,29 +1,24 @@
import React, { ReactElement, ReactNode } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
import { format, parseISO, isValid } from "date-fns";
import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
type RawFileDetailsProps = {
data?: {
rawFileDetails?: RawFileDetailsType;
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
inferredMetadata?: InferredMetadata;
createdAt?: string;
};
children?: ReactNode;
};
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data || {};
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
return (
<>
<div className="max-w-2xl ml-5">
@@ -97,10 +92,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{created_at ? (
{createdAt && isValid(parseISO(createdAt)) ? (
<>
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(createdAt), "h aaaa")}
</>
) : "N/A"}
</dd>

View File

@@ -2,27 +2,27 @@ import React from "react";
import { ComicVineSearchForm } from "./ComicVineSearchForm";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { EditMetadataPanel } from "./EditMetadataPanel";
import { RawFileDetails } from "../../graphql/generated";
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
type InferredIssue = {
name?: string;
number?: number;
year?: string;
subtitle?: string;
[key: string]: any;
};
type CVMatchesPanelProps = {
interface CVMatchesPanelProps {
rawFileDetails?: RawFileDetails;
inferredMetadata: {
issue?: InferredIssue;
};
inferredMetadata: InferredMetadata;
comicVineMatches: any[];
comicObjectId: string;
queryClient: any;
onMatchApplied: () => void;
};
/**
* Sliding panel content for ComicVine match search.
*
* Renders a search form pre-populated from `rawFileDetails`, a preview of the
* inferred issue being searched for, and a list of ComicVine match candidates
* the user can apply to the comic.
*
* @param props.onMatchApplied - Called after the user selects and applies a match,
* allowing the parent to close the panel and refresh state.
*/
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
rawFileDetails,
inferredMetadata,
@@ -62,4 +62,4 @@ type EditMetadataPanelWrapperProps = {
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
rawFileDetails,
}) => <EditMetadataPanel data={rawFileDetails} />;
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;

View File

@@ -47,10 +47,12 @@ export const TabControls = (props): ReactElement => {
</nav>
</div>
</div>
<Suspense>
{filteredTabs.map(({ id, content }) => {
return currentActive === id ? content : null;
})}
<Suspense fallback={null}>
{filteredTabs.map(({ id, content }) => (
<React.Fragment key={id}>
{currentActive === id ? content : null}
</React.Fragment>
))}
</Suspense>
</>
);

View File

@@ -131,10 +131,12 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
enabled: false,
});
if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
}
useEffect(() => {
if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
}
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
// sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {

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,14 +1,201 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useMemo, useState } from "react";
import { isEmpty, isNil } from "lodash";
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";
export const VolumeInformation = (props): ReactElement => {
interface ComicVineMetadata {
volumeInformation?: Record<string, unknown>;
name?: string;
number?: string;
resource_type?: string;
id?: number;
}
interface SourcedMetadata {
comicvine?: ComicVineMetadata;
locg?: Record<string, unknown>;
comicInfo?: unknown;
metron?: unknown;
gcd?: unknown;
[key: string]: unknown;
}
interface VolumeInformationData {
id?: string;
sourcedMetadata?: SourcedMetadata;
inferredMetadata?: { issue?: unknown };
updatedAt?: string;
}
interface VolumeInformationProps {
data: VolumeInformationData;
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",
"locg",
"comicInfo",
"metron",
"gcd",
];
const SOURCE_LABELS: Record<string, string> = {
comicvine: "ComicVine",
locg: "League of Comic Geeks",
comicInfo: "ComicInfo.xml",
metron: "Metron",
gcd: "Grand Comics Database",
inferredMetadata: "Local File",
};
const SOURCE_ICONS: Record<string, string> = {
comicvine: "icon-[solar--database-bold]",
locg: "icon-[solar--users-group-rounded-outline]",
comicInfo: "icon-[solar--file-text-outline]",
metron: "icon-[solar--planet-outline]",
gcd: "icon-[solar--book-outline]",
inferredMetadata: "icon-[solar--folder-outline]",
};
const MetadataSourceChips = ({
sources,
onOpenReconciler,
}: {
sources: string[];
onOpenReconciler: () => void;
}): ReactElement => {
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
</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>
<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={onOpenReconciler}
>
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
Reconcile sources
</button>
</div>
);
};
/**
* Displays volume metadata for a comic.
*
* - When multiple sources are present, renders a chip bar listing each source
* with a "Reconcile sources" action to merge them.
* - When exactly one source is present and it is ComicVine, renders the full
* ComicVine detail panel directly.
*
* @param props.data - Comic data containing sourced and inferred metadata.
* @param props.onReconcile - Called when the user triggers source reconciliation.
*/
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) => {
const val = (data?.sourcedMetadata ?? {})[key];
if (isNil(val) || isEmpty(val)) return false;
// locg returns an object even when empty; require at least one non-null value
if (key === "locg")
return Object.values(val as Record<string, unknown>).some(
(v) => !isNil(v) && v !== "",
);
return true;
});
if (
!isNil(data?.inferredMetadata?.issue) &&
!isEmpty(data?.inferredMetadata?.issue)
) {
sources.push("inferredMetadata");
}
return sources;
}, [data?.sourcedMetadata, data?.inferredMetadata]);
return (
<div key={1}>
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
{presentSources.length > 1 && (
<MetadataSourceChips
sources={presentSources}
onOpenReconciler={() => setReconcilerOpen(true)}
/>
)}
{presentSources.length === 1 &&
data.sourcedMetadata?.comicvine?.volumeInformation && (
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
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

@@ -2,7 +2,6 @@ import React, { lazy } from "react";
import { isNil, isEmpty } from "lodash";
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
const ComicInfoXML = lazy(() => import("./Tabs/ComicInfoXML").then(m => ({ default: m.ComicInfoXML })));
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
@@ -18,26 +17,26 @@ interface TabConfig {
interface TabConfigParams {
data: any;
comicInfo: any;
isComicBookMetadataAvailable: boolean;
hasAnyMetadata: boolean;
areRawFileDetailsAvailable: boolean;
airDCPPQuery: any;
comicObjectId: string;
userSettings: any;
issueName: string;
acquisition?: any;
onReconcileMetadata?: () => void;
}
export const createTabConfig = ({
data,
comicInfo,
isComicBookMetadataAvailable,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId,
userSettings,
issueName,
acquisition,
onReconcileMetadata,
}: TabConfigParams): TabConfig[] => {
return [
{
@@ -46,23 +45,10 @@ export const createTabConfig = ({
icon: (
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data} key={1} />
content: hasAnyMetadata ? (
<VolumeInformation data={data} onReconcile={onReconcileMetadata} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: (
<div key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
),
shouldShow: !isEmpty(comicInfo),
shouldShow: hasAnyMetadata,
},
{
id: 3,
@@ -70,7 +56,7 @@ export const createTabConfig = ({
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "Archive Operations",
content: <ArchiveOperations data={data} key={3} />,
content: <ArchiveOperations data={data} />,
shouldShow: areRawFileDetailsAvailable,
},
{
@@ -85,7 +71,6 @@ export const createTabConfig = ({
comicObjectId={comicObjectId}
comicObject={data}
settings={userSettings}
key={4}
/>
),
shouldShow: true,
@@ -112,7 +97,7 @@ export const createTabConfig = ({
),
content:
!isNil(data) && !isEmpty(data) ? (
<DownloadsPanel key={5} />
<DownloadsPanel />
) : (
<div className="column is-three-fifths">
<article className="message is-info">

View File

@@ -1,105 +1,106 @@
import React, { ReactElement } from "react";
import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type LibraryStatisticsProps = {
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
comicDirectorySize: DirectorySize;
comicsMissingFiles: number;
};
export const LibraryStatistics = (
props: LibraryStatisticsProps,
): ReactElement => {
const { stats } = props;
/** Props for {@link LibraryStatistics}. */
interface LibraryStatisticsProps {
stats: Stats | null | undefined;
}
/**
* Displays a snapshot of library metrics: total comic files, tagging coverage,
* file-type breakdown, and the publisher with the most issues.
*
* Returns `null` when `stats` is absent or the statistics array is empty.
*/
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
if (!stats || !stats.totalDocuments) return null;
const facet = stats.statistics?.[0];
if (!facet) return null;
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
const topPublisher = publisherWithMostComicsInLibrary?.[0];
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>
}
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="mt-3">
<div className="flex flex-row gap-5">
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Library size</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</dd>
{props.stats.comicDirectorySize?.fileCount && (
<dd>
<span className="text-2xl text-green-600">
{props.stats.comicDirectorySize.fileCount} comic files
</span>
</dd>
)}
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issues) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issues?.length || 0}
</span>{" "}
tagged with ComicVine
</div>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0}
</span>{" "}
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
with ComicInfo.xml
</span>
</div>
)}
</div>
<div className="">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
return (
<span
key={idx}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
>
{fileType.data.length} {fileType.id}
</span>
);
})}
</div>
{/* file types */}
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
{/* publisher with most issues */}
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0],
) && (
<>
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.id
}
</span>
{" has the most issues "}
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.count
}
</span>
</>
)}
</div>
<div className="mt-3 flex flex-row gap-5">
{/* Total records in database */}
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.totalDocuments} comics
</dd>
</div>
{/* Missing files */}
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
<dd className="text-3xl text-red-600 md:text-5xl">
{stats.comicsMissingFiles}
</dd>
</div>
{/* Disk space consumed */}
{stats.comicDirectorySize.totalSizeInGB != null && (
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
</dd>
</div>
)}
{/* Tagging coverage */}
<div className="flex flex-col gap-4">
{issues && issues.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issues.length}</span>
tagged with ComicVine
</div>
)}
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
with ComicInfo.xml
</div>
)}
</div>
{/* File-type breakdown */}
{fileTypes && fileTypes.length > 0 && (
<div>
{fileTypes.map((ft) => (
<span
key={ft.id}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
>
{ft.data.length} {ft.id}
</span>
))}
</div>
)}
{/* Publisher with most issues */}
{topPublisher && (
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
<span>{topPublisher.id}</span>
{" has the most issues "}
<span>{topPublisher.count}</span>
</div>
)}
</div>
</div>
);

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."
@@ -42,6 +43,7 @@ export const RecentlyImported = (
sourcedMetadata,
canonicalMetadata,
inferredMetadata,
importStatus,
} = comic;
// Parse sourced metadata (GraphQL returns as strings)
@@ -63,7 +65,10 @@ export const RecentlyImported = (
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
return (
<div
key={idx}
@@ -127,12 +132,6 @@ export const RecentlyImported = (
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
</div>

View File

@@ -11,9 +11,11 @@ type VolumeGroupsProps = {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
};
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
const navigate = useNavigate();
const navigateToVolumes = (row: any) => {
navigate(`/volumes/all`);
@@ -29,6 +31,7 @@ export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
return (
<div>
{/* TODO: Switch iconClassNames to Solar icon */}
<Header
headerContent="Volumes"
subHeaderContent={<>Based on ComicVine Volume information</>}

View File

@@ -15,7 +15,9 @@ type WantedComicsListProps = {
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
}: WantedComicsListProps): ReactElement | null => {
if (!comics || comics.length === 0) return null;
const navigate = useNavigate();
// embla carousel
@@ -28,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

@@ -1,30 +1,17 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import {
useGetJobResultStatisticsQuery,
useGetImportStatisticsQuery,
useStartIncrementalImportMutation
} from "../../graphql/generated";
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
interface ImportProps {
path: string;
}
/**
* Import component for adding comics to the ThreeTwo library.
* Provides preview statistics, smart import, and queue management.
*/
export const Import = (props: ImportProps): ReactElement => {
const queryClient = useQueryClient();
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
@@ -33,30 +20,6 @@ export const Import = (props: ImportProps): ReactElement => {
})),
);
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const { mutate: initiateImport } = useMutation({
mutationFn: async () => {
const sessionId = localStorage.getItem("sessionId");
return await axios.request({
url: `http://localhost:3000/api/library/newImport`,
method: "POST",
data: { sessionId },
});
},
});
// Force re-import mutation - re-imports all files regardless of import status
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
mutationFn: async () => {
@@ -78,101 +41,57 @@ export const Import = (props: ImportProps): ReactElement => {
},
});
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
// Get import statistics to determine if Start Import button should be shown
const { data: importStats } = useGetImportStatisticsQuery(
{},
{
refetchOnWindowFocus: false,
refetchInterval: false,
}
);
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
// Use custom hook for definitive import session status tracking
// NO POLLING - relies on Socket.IO events only
const importSession = useImportSessionStatus();
const hasActiveSession = importSession.isActive;
const wasComplete = useRef(false);
// Determine if we should show the Start Import button
const hasNewFiles = importStats?.getImportStatistics?.success &&
importStats.getImportStatistics.stats &&
importStats.getImportStatistics.stats.newFiles > 0;
// React to importSession.isComplete rather than socket events — more reliable
// since it's derived from the actual GraphQL state, not a raw socket event.
useEffect(() => {
if (importSession.isComplete && !wasComplete.current) {
wasComplete.current = true;
// Small delay so the backend has time to commit job result stats
setTimeout(() => {
// Invalidate the cache to force a fresh fetch of job result statistics
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
refetch();
}, 1500);
importJobQueue.setStatus("drained");
} else if (!importSession.isComplete) {
wasComplete.current = false;
}
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
// Listen to socket events to update Past Imports table in real-time
useEffect(() => {
const socket = getSocket("/");
const handleQueueDrained = () => refetch();
const handleCoverExtracted = () => refetch();
const handleSessionStarted = () => {
importJobQueue.setStatus("running");
const handleImportCompleted = () => {
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
const handleSessionCompleted = () => {
refetch();
importJobQueue.setStatus("drained");
const handleQueueDrained = () => {
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
};
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]);
/**
* Toggles import queue pause/resume state
*/
const toggleQueue = (queueAction: string, queueStatus: string) => {
const socket = getSocket("/");
socket.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
);
};
/**
* Starts smart import with race condition prevention
*/
const handleStartSmartImport = async () => {
// Clear any previous errors
setImportError(null);
// Check for active session before starting using definitive status
if (hasActiveSession) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
);
return;
}
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}, 500);
}, 100);
} else {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}
};
}, [getSocket, queryClient]);
/**
* Handles force re-import - re-imports all files to fix indexing issues
@@ -197,7 +116,6 @@ export const Import = (props: ImportProps): ReactElement => {
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
forceReImport();
}, 500);
@@ -208,54 +126,6 @@ export const Import = (props: ImportProps): ReactElement => {
}
};
/**
* Renders pause/resume controls based on queue status
*/
const renderQueueControls = (status: string): ReactElement | null => {
switch (status) {
case "running":
return (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("pause", "paused");
importJobQueue.setStatus("paused");
}}
>
<span className="text-md">Pause</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button>
</div>
);
case "paused":
return (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("resume", "running");
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Resume</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button>
</div>
);
case "drained":
return null;
default:
return null;
}
};
return (
<div>
<section>
@@ -327,53 +197,10 @@ export const Import = (props: ImportProps): ReactElement => {
</div>
)}
{/* Active Session Warning */}
{hasActiveSession && !hasNewFiles && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-yellow-600 dark:text-yellow-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--info-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-yellow-800 dark:text-yellow-300">
Import In Progress
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
An import session is currently active. New imports cannot be started until it completes.
</p>
</div>
</div>
</div>
)}
{/* Import Action Buttons */}
<div className="my-6 max-w-screen-lg flex flex-col sm:flex-row gap-3">
{/* Start Smart Import Button - shown when there are new files, no active session, and no import is running */}
{hasNewFiles &&
!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleStartSmartImport}
disabled={isStartingImport || hasActiveSession}
>
<span className="text-md font-medium">
{isStartingImport
? "Starting Import..."
: importStats?.getImportStatistics?.stats?.alreadyImported === 0
? `Start Import (${importStats?.getImportStatistics?.stats?.newFiles} files)`
: `Start Incremental Import (${importStats?.getImportStatistics?.stats?.newFiles} new files)`
}
</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<div className="my-6 max-w-screen-lg">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleForceReImport}
@@ -387,8 +214,8 @@ export const Import = (props: ImportProps): ReactElement => {
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
</span>
</button>
)}
</div>
</div>
)}
{/* Import activity is now shown in the RealTimeImportStats component above */}

View File

@@ -1,93 +1,167 @@
import React, { ReactElement, useEffect, useState } from "react";
import { ReactElement, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
useGetImportStatisticsQuery,
useStartIncrementalImportMutation
useGetWantedComicsQuery,
useStartIncrementalImportMutation,
} from "../../graphql/generated";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
/**
* Import statistics with card-based layout and progress bar
* Updates in real-time via the useImportSessionStatus hook
* Import statistics with card-based layout and progress bar.
* Three states: pre-import (idle), importing (active), and post-import (complete).
* Also surfaces missing files detected by the file watcher.
*/
export const RealTimeImportStats = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const [detectedFile, setDetectedFile] = useState<string | null>(null);
const [socketImport, setSocketImport] = useState<{
active: boolean;
completed: number;
total: number;
failed: number;
} | null>(null);
const queryClient = useQueryClient();
const { getSocket, disconnectSocket, importJobQueue } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
importJobQueue: state.importJobQueue,
}))
})),
);
// Get filesystem statistics (new files vs already imported)
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false }
{ refetchOnWindowFocus: false, refetchInterval: false },
);
// Get definitive import session status (handles Socket.IO events internally)
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const stats = importStats?.getImportStatistics?.stats;
// File list for the detail panel — only fetched when there are missing files
const { data: missingComicsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 3, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{
refetchOnWindowFocus: false,
refetchInterval: false,
enabled: (stats?.missingFiles ?? 0) > 0,
},
);
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
const getMissingComicLabel = (comic: any): string => {
const series =
comic.canonicalMetadata?.series?.value ??
comic.inferredMetadata?.issue?.name;
const issueNum =
comic.canonicalMetadata?.issueNumber?.value ??
comic.inferredMetadata?.issue?.number;
if (series && issueNum) return `${series} #${issueNum}`;
if (series) return series;
return comic.rawFileDetails?.name ?? comic.id;
};
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } =
useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
setImportError(
error?.message || "Failed to start import. Please try again.",
);
},
});
const hasNewFiles = stats && stats.newFiles > 0;
const missingCount = stats?.missingFiles ?? 0;
// Refetch filesystem stats when import completes
useEffect(() => {
if (importSession.isComplete && importSession.status === "completed") {
console.log("[RealTimeImportStats] Import completed, refetching filesystem stats");
refetchStats();
importJobQueue.setStatus("drained");
}
}, [importSession.isComplete, importSession.status, refetchStats, importJobQueue]);
// Listen to filesystem change events to refetch stats
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
useEffect(() => {
const socket = getSocket("/");
const handleFilesystemChange = () => {
refetchStats();
const handleStatsChange = () => {
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
};
// File system changes that affect import statistics
socket.on("LS_FILE_ADDED", handleFilesystemChange);
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
const handleFileDetected = (payload: { filePath: string }) => {
handleStatsChange();
const name = payload.filePath.split("/").pop() ?? payload.filePath;
setDetectedFile(name);
setTimeout(() => setDetectedFile(null), 5000);
};
const handleImportStarted = () => {
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
};
const handleCoverExtracted = (payload: {
completedJobCount: number;
totalJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) => ({
active: true,
completed: payload.completedJobCount,
total: payload.totalJobCount,
failed: prev?.failed ?? 0,
}));
};
const handleCoverExtractionFailed = (payload: {
failedJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) =>
prev ? { ...prev, failed: payload.failedJobCount } : null,
);
};
const handleQueueDrained = () => {
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
handleStatsChange();
};
socket.on("LS_LIBRARY_STATS", handleStatsChange);
socket.on("LS_FILES_MISSING", handleStatsChange);
socket.on("LS_FILE_DETECTED", handleFileDetected);
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("LS_FILE_ADDED", handleFilesystemChange);
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
socket.off("LS_LIBRARY_STATS", handleStatsChange);
socket.off("LS_FILES_MISSING", handleStatsChange);
socket.off("LS_FILE_DETECTED", handleFileDetected);
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
};
}, [getSocket, refetchStats]);
}, [getSocket, queryClient]);
const handleStartImport = async () => {
setImportError(null);
// Check if import is already active using definitive status
if (importSession.isActive) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
);
return;
}
@@ -112,24 +186,26 @@ export const RealTimeImportStats = (): ReactElement => {
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
}
// Determine button text based on whether there are already imported files
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`
: `Start Incremental Import (${stats.newFiles} new files)`;
// Calculate display statistics
const displayStats = importSession.isActive && importSession.stats
? {
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
filesQueued: importSession.stats.filesQueued,
filesSucceeded: importSession.stats.filesSucceeded,
}
: {
totalFiles: stats.totalLocalFiles,
filesQueued: stats.newFiles,
filesSucceeded: stats.alreadyImported,
};
// Determine what to show in each card based on current phase
const sessionStats = importSession.stats;
const hasSessionStats = importSession.isActive && sessionStats !== null;
const totalFiles = stats.totalLocalFiles;
const importedCount = stats.alreadyImported;
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
const showProgressBar = socketImport !== null;
const socketProgressPct =
socketImport && socketImport.total > 0
? Math.round((socketImport.completed / socketImport.total) * 100)
: 0;
const showFailedCard = hasSessionStats && failedCount > 0;
const showMissingCard = missingCount > 0;
return (
<div className="space-y-6">
@@ -141,89 +217,161 @@ export const RealTimeImportStats = (): ReactElement => {
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">Import Error</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{importError}</p>
<p className="font-semibold text-red-800 dark:text-red-300">
Import Error
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
{importError}
</p>
</div>
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</span>
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</button>
</div>
</div>
)}
{/* Import Button - only show when there are new files and no active import */}
{/* File detected toast */}
{detectedFile && (
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
New file detected: {detectedFile}
</p>
</div>
)}
{/* Start Import button — only when idle with new files */}
{hasNewFiles && !importSession.isActive && (
<button
onClick={handleStartImport}
disabled={isStartingImport}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
</button>
)}
{/* Active Import Progress Bar */}
{importSession.isActive && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
{/* Progress bar — shown while importing and once complete */}
{showProgressBar && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{socketImport!.active
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
: `${socketImport!.completed} / ${socketImport!.total} imported`}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{Math.round(importSession.progress)}%
<span className="font-semibold text-gray-900 dark:text-white">
{socketProgressPct}% complete
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
style={{ width: `${importSession.progress}%` }}
style={{ width: `${socketProgressPct}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
{socketImport!.active && (
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
)}
</div>
</div>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Files Detected Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.totalFiles}
</div>
<div className="text-sm text-gray-200 font-medium">
files detected
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Total files */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#6b7280" }}
>
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
<div className="text-sm text-gray-200 font-medium">in import folder</div>
</div>
{/* To Import Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.filesQueued}
</div>
<div className="text-sm text-gray-100 font-medium">
to import
</div>
</div>
{/* Already Imported Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#d8dab2' }}>
{/* Imported */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#d8dab2" }}
>
<div className="text-4xl font-bold text-gray-800 mb-2">
{displayStats.filesSucceeded}
{importedCount}
</div>
<div className="text-sm text-gray-700 font-medium">
already imported
{importSession.isActive ? "imported so far" : "imported in database"}
</div>
</div>
{/* Failed — only shown after a session with failures */}
{showFailedCard && (
<div className="rounded-lg p-6 text-center bg-red-500">
<div className="text-4xl font-bold text-white mb-2">
{failedCount}
</div>
<div className="text-sm text-red-100 font-medium">failed</div>
</div>
)}
{/* Missing files — shown when watcher detects moved/deleted files */}
{showMissingCard && (
<div className="rounded-lg p-6 text-center bg-card-missing">
<div className="text-4xl font-bold text-slate-700 mb-2">
{missingCount}
</div>
<div className="text-sm text-slate-800 font-medium">missing</div>
</div>
)}
</div>
{/* Missing files detail panel */}
{showMissingCard && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-800 dark:text-amber-300">
{missingCount} {missingCount === 1 ? "file" : "files"} missing
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
These files were previously imported but can no longer be found
on disk. Move them back to restore access.
</p>
{missingDocs.length > 0 && (
<ul className="mt-2 space-y-1">
{missingDocs.map((comic, i) => (
<li
key={i}
className="text-xs text-amber-700 dark:text-amber-400 truncate"
>
{getMissingComicLabel(comic)} is missing
</li>
))}
{missingCount > 3 && (
<li className="text-xs text-amber-600 dark:text-amber-500">
and {missingCount - 3} more.
</li>
)}
</ul>
)}
<Link
to="/library?filter=missingFiles"
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
>
<span className="underline">
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
View Missing Files In Library
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
</span>
</Link>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import React, { useMemo, ReactElement, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
@@ -12,79 +11,130 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
import { format, parseISO } from "date-fns";
import { useGetWantedComicsQuery } from "../../graphql/generated";
type FilterOption = "all" | "missingFiles";
interface SearchQuery {
query: Record<string, any>;
pagination: { size: number; from: number };
type: string;
trigger: string;
}
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: "all", label: "All Comics" },
{ value: "missingFiles", label: "Missing Files" },
];
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
* Library page component. Displays a paginated, searchable table of all comics
* in the collection, with an optional filter for comics with missing raw files.
*/
export const Library = (): ReactElement => {
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({
const [searchParams] = useSearchParams();
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {},
pagination: {
size: 25,
from: offset,
},
pagination: { size: 25, from: 0 },
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/**
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
/** Fetches a page of issues from the search API. */
const fetchIssues = async (q: SearchQuery) => {
const { pagination, query, type } = q;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
query,
pagination,
type,
},
data: { query, pagination, type },
});
};
const searchIssues = (e) => {
const { data, isPlaceholderData } = useQuery({
queryKey: ["comics", searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
enabled: activeFilter === "all",
});
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 25, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "missingFiles" },
);
const { data: missingIdsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 1000, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "all" },
);
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
const missingIdSet = useMemo(
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
[missingIdsData],
);
const searchResults = data?.data;
const navigate = useNavigate();
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
/** Triggers a search by volume name and resets pagination. */
const searchIssues = (e: any) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
query: { volumeName: e.search },
pagination: { size: 15, from: 0 },
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
/** Advances to the next page of results. */
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from: pageSize * pageIndex + 1 },
type: "all",
trigger: "libraryPage",
});
}
};
const ComicInfoXML = (value) => {
return value.data ? (
/** Goes back to the previous page of results. */
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from },
type: "all",
trigger: "libraryPage",
});
};
const ComicInfoXML = (value: any) =>
value.data ? (
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
{/* Series Name */}
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
@@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
</span>
</span>
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
{/* Pages */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
@@ -103,7 +152,6 @@ export const Library = (): ReactElement => {
Pages: {value.data.pagecount[0]}
</span>
</span>
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
@@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
</div>
</dl>
) : null;
};
const missingFilesColumns = useMemo(
() => [
{
header: "Missing Files",
columns: [
{
header: "Status",
id: "missingStatus",
cell: () => (
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
MISSING
</span>
</div>
),
},
{
header: "Comic",
id: "missingComic",
minWidth: 250,
accessorFn: (row: any) => row,
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
},
],
},
],
[],
);
const columns = useMemo(
() => [
{
header: "Comic Metadata",
footer: 1,
columns: [
{
header: "File Details",
id: "fileDetails",
minWidth: 250,
accessorKey: "_source",
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
cell: (info: any) => {
const source = info.getValue();
return (
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : null,
cell: (info: any) =>
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
},
],
},
@@ -150,36 +230,30 @@ export const Library = (): ReactElement => {
{
header: "Date of Import",
accessorKey: "_source.createdAt",
cell: (info) => {
return !isNil(info.getValue()) ? (
cell: (info: any) =>
!isNil(info.getValue()) ? (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
{format(parseISO(info.getValue()), "h aaaa")}
</div>
) : null;
},
) : null,
},
{
header: "Downloads",
accessorKey: "_source.acquisition",
cell: (info) => (
cell: (info: any) => (
<div className="flex flex-col gap-2 ml-3 my-3">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
DC++: {info.getValue().directconnect.downloads.length}
</span>
DC++: {info.getValue().directconnect.downloads.length}
</span>
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.length}
</span>
Torrent: {info.getValue().torrent.length}
</span>
</div>
),
@@ -187,130 +261,100 @@ export const Library = (): ReactElement => {
],
},
],
[],
[missingIdSet],
);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
}
};
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(from);
};
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{!isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<div>
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
>
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div>
</div>
) : (
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come
back.
</p>
</div>
</article>
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
{!isUndefined(searchResults?.data?.meta?.body) ? (
<p>
{JSON.stringify(
searchResults?.data.meta.body.error.root_cause,
null,
4,
)}
</p>
) : null}
</pre>
</div>
</div>
)}
</section>
const FilterDropdown = () => (
<div className="relative">
<select
value={activeFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
</div>
);
const isMissingFilter = activeFilter === "missingFiles";
return (
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{isMissingFilter ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{isMissingLoading ? (
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
) : (
<T2Table
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
columns={missingFilesColumns}
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
rowClickHandler={navigateToMissingComicDetail}
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
>
<FilterDropdown />
</T2Table>
)}
</div>
) : !isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
getRowClassName={(row) =>
missingIdSet.has(row.original._id)
? "bg-card-missing/40 hover:bg-card-missing/20"
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
}
paginationHandlers={{ nextPage, previousPage }}
>
<div className="flex items-center gap-2">
<FilterDropdown />
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
</div>
</T2Table>
</div>
) : (
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no indices. Try
importing a few comics into the library and come back.
</p>
</div>
</article>
<FilterDropdown />
</div>
)}
</section>
);
};
export default Library;

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

@@ -10,7 +10,7 @@ interface ICardProps {
children?: PropTypes.ReactNodeLike;
borderColorClass?: string;
backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: React.CSSProperties;
imageStyle?: React.CSSProperties;
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
return "bg-card-uncompressed";
case "imported":
return "bg-card-imported";
case "missing":
return "bg-card-missing";
default:
return "";
}
@@ -102,11 +104,22 @@ const renderCard = (props: ICardProps): ReactElement => {
case "vertical-2":
return (
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
<div className="relative">
{props.imageUrl ? (
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
) : (
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
)}
{props.cardState === "missing" && (
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
<i className="icon-[solar--file-corrupted-outline] w-16 h-16 text-red-500" />
</div>
)}
</div>
{props.title ? (
<div className="px-3 pt-3 mb-2">

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

@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash";
interface IMetadatPanelProps {
value: any;
children: any;
imageStyle: any;
titleStyle: any;
tagsStyle: any;
containerStyle: any;
data: any;
value?: any;
children?: any;
imageStyle?: any;
titleStyle?: any;
tagsStyle?: any;
containerStyle?: any;
isMissing?: boolean;
}
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const { isMissing = false } = props;
const {
rawFileDetails,
inferredMetadata,
@@ -31,8 +34,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{
name: "rawFileDetails",
content: () => (
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
<dt>
<dl
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
>
<dt className="flex items-center gap-2">
<p className="text-sm sm:text-lg">{issueName}</p>
</dt>
<dd className="text-xs sm:text-sm">
@@ -58,26 +63,35 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)}
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
{rawFileDetails.mimeType && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
)}
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
{rawFileDetails.fileSize != null && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
)}
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
{/* Missing file Icon */}
{isMissing && (
<span className="pr-2 pt-1" title="File backing this comic is missing">
<i className="icon-[solar--file-corrupted-outline] w-5 h-5 text-red-600 shrink-0"></i>
</span>
</span>
)}
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
@@ -177,7 +191,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const metadataPanel = find(metadataContentPanel, {
name: objectReference,
});
return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
@@ -188,7 +201,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
imageStyle={props.imageStyle}
/>
</div>
<div className="flex-1">{metadataPanel.content()}</div>
<div className="flex-1">{metadataPanel?.content()}</div>
</div>
);
};

View File

@@ -1,69 +1,89 @@
import React, { ReactElement, useMemo, useState } from "react";
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import {
ColumnDef,
Row,
flexRender,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
PaginationState,
} from "@tanstack/react-table";
interface T2TableProps {
sourceData?: unknown[];
/** Props for {@link T2Table}. */
interface T2TableProps<TData> {
/** Row data to render. */
sourceData?: TData[];
/** Total number of records across all pages, used for pagination display. */
totalPages?: number;
columns?: unknown[];
/** Column definitions (TanStack Table {@link ColumnDef} array). */
columns?: ColumnDef<TData>[];
/** Callbacks for navigating between pages. */
paginationHandlers?: {
nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown;
nextPage?(pageIndex: number, pageSize: number): void;
previousPage?(pageIndex: number, pageSize: number): void;
};
rowClickHandler?(...args: unknown[]): unknown;
children?: any;
/** Called with the TanStack row object when a row is clicked. */
rowClickHandler?(row: Row<TData>): void;
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
getRowClassName?(row: Row<TData>): string;
/** Optional slot rendered in the toolbar area (e.g. a search input). */
children?: ReactNode;
}
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const {
sourceData,
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
} = tableOptions;
/**
* A paginated data table with a two-row sticky header.
*
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
* element placed immediately before the table. The second header row's `top`
* offset is measured at mount time so both rows stay flush regardless of font
* size or padding changes.
*/
export const T2Table = <TData,>({
sourceData = [],
columns = [],
paginationHandlers: { nextPage, previousPage } = {},
totalPages = 0,
rowClickHandler,
getRowClassName,
children,
}: T2TableProps<TData>): ReactElement => {
const sentinelRef = useRef<HTMLDivElement>(null);
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
const [isSticky, setIsSticky] = useState(false);
const [firstRowHeight, setFirstRowHeight] = useState(0);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => setIsSticky(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (firstHeaderRowRef.current)
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
}, []);
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
/**
* Pagination control to move forward one page
* @returns void
*/
/** Advances to the next page and notifies the parent. */
const goToNextPage = () => {
setPagination({
pageIndex: pageIndex + 1,
pageSize,
});
nextPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex + 1, pageSize });
nextPage?.(pageIndex, pageSize);
};
/**
* Pagination control to move backward one page
* @returns void
**/
/** Goes back one page and notifies the parent. */
const goToPreviousPage = () => {
setPagination({
pageIndex: pageIndex - 1,
pageSize,
});
previousPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex - 1, pageSize });
previousPage?.(pageIndex, pageSize);
};
const table = useReactTable({
@@ -72,63 +92,62 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1,
state: {
pagination,
},
state: { pagination },
onPaginationChange: setPagination,
});
return (
<div className="container max-w-fit">
<div>
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{/* Search bar */}
{tableOptions.children}
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{children}
{/* Pagination controls */}
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={() => goToPreviousPage()}
disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
>
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
</button>
<button
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
</button>
</div>
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={goToPreviousPage}
disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
>
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
</button>
<button
onClick={goToNextPage}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
</button>
</div>
</div>
</div>
<div ref={sentinelRef} />
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900">
<thead>
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, index) => (
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400 border-b border-gray-300 dark:border-slate-700"
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
className={[
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
groupIndex === 0
? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
].join(' ')}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
@@ -136,11 +155,11 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
</thead>
<tbody>
{table.getRowModel().rows.map((row, rowIndex) => (
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => rowClickHandler(row)}
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer"
onClick={() => rowClickHandler?.(row)}
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top">

View File

@@ -28,6 +28,23 @@ export type AcquisitionSourceInput = {
wanted?: InputMaybe<Scalars['Boolean']['input']>;
};
export type AddTorrentInput = {
comicObjectId: Scalars['ID']['input'];
torrentToDownload: Scalars['String']['input'];
};
export type AddTorrentResult = {
__typename?: 'AddTorrentResult';
result?: Maybe<Scalars['JSON']['output']>;
};
export type AppSettings = {
__typename?: 'AppSettings';
bittorrent?: Maybe<BittorrentSettings>;
directConnect?: Maybe<DirectConnectSettings>;
prowlarr?: Maybe<ProwlarrSettings>;
};
export type Archive = {
__typename?: 'Archive';
expandedPath?: Maybe<Scalars['String']['output']>;
@@ -52,27 +69,61 @@ export type AutoMergeSettingsInput = {
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
};
export type BittorrentClient = {
__typename?: 'BittorrentClient';
host?: Maybe<HostConfig>;
name?: Maybe<Scalars['String']['output']>;
};
export type BittorrentSettings = {
__typename?: 'BittorrentSettings';
client?: Maybe<BittorrentClient>;
};
export type Bundle = {
__typename?: 'Bundle';
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
size?: Maybe<Scalars['String']['output']>;
speed?: Maybe<Scalars['String']['output']>;
status?: Maybe<Scalars['String']['output']>;
};
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>;
};
@@ -123,6 +174,11 @@ export type ComicConnection = {
totalCount: Scalars['Int']['output'];
};
export type ComicVineMatchInput = {
volume: ComicVineVolumeRefInput;
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
};
export type ComicVineResourceResponse = {
__typename?: 'ComicVineResourceResponse';
error: Scalars['String']['output'];
@@ -143,6 +199,24 @@ export type ComicVineSearchResult = {
offset: Scalars['Int']['output'];
results: Array<SearchResultItem>;
status_code: Scalars['Int']['output'];
total: Scalars['Int']['output'];
};
export type ComicVineVolume = {
__typename?: 'ComicVineVolume';
api_detail_url?: Maybe<Scalars['String']['output']>;
count_of_issues?: Maybe<Scalars['Int']['output']>;
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
image?: Maybe<VolumeImage>;
name?: Maybe<Scalars['String']['output']>;
publisher?: Maybe<Publisher>;
site_detail_url?: Maybe<Scalars['String']['output']>;
start_year?: Maybe<Scalars['String']['output']>;
};
export type ComicVineVolumeRefInput = {
api_detail_url: Scalars['String']['input'];
};
export enum ConflictResolutionStrategy {
@@ -166,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'];
@@ -177,10 +252,22 @@ export type DirectConnectBundleInput = {
size?: InputMaybe<Scalars['String']['input']>;
};
export type DirectConnectClient = {
__typename?: 'DirectConnectClient';
airDCPPUserSettings?: Maybe<Scalars['JSON']['output']>;
host?: Maybe<HostConfig>;
hubs?: Maybe<Array<Maybe<Scalars['JSON']['output']>>>;
};
export type DirectConnectInput = {
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
};
export type DirectConnectSettings = {
__typename?: 'DirectConnectSettings';
client?: Maybe<DirectConnectClient>;
};
export type DirectorySize = {
__typename?: 'DirectorySize';
fileCount: Scalars['Int']['output'];
@@ -189,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'];
@@ -223,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']>;
@@ -234,6 +337,37 @@ export type GetVolumesInput = {
volumeURI: Scalars['String']['input'];
};
export type HostConfig = {
__typename?: 'HostConfig';
hostname?: Maybe<Scalars['String']['output']>;
password?: Maybe<Scalars['String']['output']>;
port?: Maybe<Scalars['String']['output']>;
protocol?: Maybe<Scalars['String']['output']>;
username?: Maybe<Scalars['String']['output']>;
};
export type HostInput = {
hostname: Scalars['String']['input'];
password: Scalars['String']['input'];
port: Scalars['String']['input'];
protocol: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type Hub = {
__typename?: 'Hub';
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
userCount?: Maybe<Scalars['Int']['output']>;
};
export type ImageAnalysisResult = {
__typename?: 'ImageAnalysisResult';
analyzedData?: Maybe<Scalars['JSON']['output']>;
colorHistogramData?: Maybe<Scalars['JSON']['output']>;
};
export type ImageUrls = {
__typename?: 'ImageUrls';
icon_url?: Maybe<Scalars['String']['output']>;
@@ -303,6 +437,7 @@ export type ImportStatistics = {
export type ImportStats = {
__typename?: 'ImportStats';
alreadyImported: Scalars['Int']['output'];
missingFiles: Scalars['Int']['output'];
newFiles: Scalars['Int']['output'];
percentageImported: Scalars['String']['output'];
totalLocalFiles: Scalars['Int']['output'];
@@ -311,6 +446,7 @@ export type ImportStats = {
export type ImportStatus = {
__typename?: 'ImportStatus';
isImported?: Maybe<Scalars['Boolean']['output']>;
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
matchedResult?: Maybe<MatchedResult>;
tagged?: Maybe<Scalars['Boolean']['output']>;
};
@@ -421,6 +557,7 @@ export type LocgMetadataInput = {
export type LibraryStatistics = {
__typename?: 'LibraryStatistics';
comicDirectorySize: DirectorySize;
comicsMissingFiles: Scalars['Int']['output'];
statistics: Array<StatisticsFacet>;
totalDocuments: Scalars['Int']['output'];
};
@@ -438,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>;
@@ -515,6 +645,10 @@ export type Mutation = {
__typename?: 'Mutation';
/** Placeholder for future mutations */
_empty?: Maybe<Scalars['String']['output']>;
/** Add a torrent to qBittorrent */
addTorrent?: Maybe<AddTorrentResult>;
analyzeImage: ImageAnalysisResult;
applyComicVineMatch: Comic;
bulkResolveMetadata: Array<Comic>;
forceCompleteSession: ForceCompleteResult;
importComic: ImportComicResult;
@@ -524,11 +658,28 @@ export type Mutation = {
setMetadataField: Comic;
startIncrementalImport: IncrementalImportResult;
startNewImport: ImportJobResult;
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
updateSourcedMetadata: Comic;
updateUserPreferences: UserPreferences;
};
export type MutationAddTorrentArgs = {
input: AddTorrentInput;
};
export type MutationAnalyzeImageArgs = {
imageFilePath: Scalars['String']['input'];
};
export type MutationApplyComicVineMatchArgs = {
comicObjectId: Scalars['ID']['input'];
match: ComicVineMatchInput;
};
export type MutationBulkResolveMetadataArgs = {
comicIds: Array<Scalars['ID']['input']>;
};
@@ -579,6 +730,13 @@ export type MutationStartNewImportArgs = {
};
export type MutationUncompressArchiveArgs = {
comicObjectId: Scalars['ID']['input'];
filePath: Scalars['String']['input'];
options?: InputMaybe<Scalars['JSON']['input']>;
};
export type MutationUpdateSourcedMetadataArgs = {
comicId: Scalars['ID']['input'];
metadata: Scalars['String']['input'];
@@ -618,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'];
@@ -627,6 +793,17 @@ export type Provenance = {
url?: Maybe<Scalars['String']['output']>;
};
export type ProwlarrClient = {
__typename?: 'ProwlarrClient';
apiKey?: Maybe<Scalars['String']['output']>;
host?: Maybe<HostConfig>;
};
export type ProwlarrSettings = {
__typename?: 'ProwlarrSettings';
client?: Maybe<ProwlarrClient>;
};
export type Publisher = {
__typename?: 'Publisher';
api_detail_url?: Maybe<Scalars['String']['output']>;
@@ -642,7 +819,9 @@ export type PublisherStats = {
export type Query = {
__typename?: 'Query';
_empty?: Maybe<Scalars['String']['output']>;
analyzeMetadataConflicts: Array<MetadataConflict>;
bundles: Array<Bundle>;
comic?: Maybe<Comic>;
comics: ComicConnection;
/** Fetch resource from Metron API */
@@ -663,13 +842,18 @@ export type Query = {
getVolume: VolumeDetailResponse;
/** Get weekly pull list from League of Comic Geeks */
getWeeklyPullList: MetadataPullListResponse;
hubs: Array<Hub>;
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
/** Search ComicVine for volumes, issues, characters, etc. */
searchComicVine: ComicVineSearchResult;
searchIssue: SearchIssueResult;
searchTorrents: Array<TorrentSearchResult>;
settings?: Maybe<AppSettings>;
torrentJobs?: Maybe<TorrentJob>;
userPreferences?: Maybe<UserPreferences>;
/** Advanced volume-based search with scoring and filtering */
volumeBasedSearch: VolumeBasedSearchResponse;
walkFolders: Array<Scalars['String']['output']>;
};
@@ -678,6 +862,12 @@ export type QueryAnalyzeMetadataConflictsArgs = {
};
export type QueryBundlesArgs = {
comicObjectId: Scalars['ID']['input'];
config?: InputMaybe<Scalars['JSON']['input']>;
};
export type QueryComicArgs = {
id: Scalars['ID']['input'];
};
@@ -733,6 +923,11 @@ export type QueryGetWeeklyPullListArgs = {
};
export type QueryHubsArgs = {
host: HostInput;
};
export type QueryPreviewCanonicalMetadataArgs = {
comicId: Scalars['ID']['input'];
preferences?: InputMaybe<UserPreferencesInput>;
@@ -751,6 +946,21 @@ export type QuerySearchIssueArgs = {
};
export type QuerySearchTorrentsArgs = {
query: Scalars['String']['input'];
};
export type QuerySettingsArgs = {
settingsKey?: InputMaybe<Scalars['String']['input']>;
};
export type QueryTorrentJobsArgs = {
trigger: Scalars['String']['input'];
};
export type QueryUserPreferencesArgs = {
userId?: InputMaybe<Scalars['String']['input']>;
};
@@ -760,6 +970,12 @@ export type QueryVolumeBasedSearchArgs = {
input: VolumeSearchInput;
};
export type QueryWalkFoldersArgs = {
basePathToWalk: Scalars['String']['input'];
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type RawFileDetails = {
__typename?: 'RawFileDetails';
archive?: Maybe<Archive>;
@@ -785,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>;
};
@@ -868,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'];
@@ -930,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']>;
@@ -938,6 +1180,39 @@ export type TeamCredit = {
site_detail_url?: Maybe<Scalars['String']['output']>;
};
export type TorrentJob = {
__typename?: 'TorrentJob';
id?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
};
export type TorrentSearchResult = {
__typename?: 'TorrentSearchResult';
downloadUrl?: Maybe<Scalars['String']['output']>;
guid?: Maybe<Scalars['String']['output']>;
indexer?: Maybe<Scalars['String']['output']>;
leechers?: Maybe<Scalars['Int']['output']>;
publishDate?: Maybe<Scalars['String']['output']>;
seeders?: Maybe<Scalars['Int']['output']>;
size?: Maybe<Scalars['Float']['output']>;
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;
@@ -1083,7 +1358,7 @@ export type GetRecentComicsQueryVariables = Exact<{
}>;
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null }> } };
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null, importStatus?: { __typename?: 'ImportStatus', isRawFileMissing?: boolean | null } | null }> } };
export type GetWantedComicsQueryVariables = Exact<{
paginationOptions: PaginationOptionsInput;
@@ -1101,7 +1376,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicsMissingFiles: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number, totalSizeInGB: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
export type GetWeeklyPullListQueryVariables = Exact<{
input: WeeklyPullListInput;
@@ -1115,7 +1390,7 @@ export type GetImportStatisticsQueryVariables = Exact<{
}>;
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } };
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, missingFiles: number, percentageImported: string } } };
export type StartNewImportMutationVariables = Exact<{
sessionId: Scalars['String']['input'];
@@ -1524,6 +1799,9 @@ export const GetRecentComicsDocument = `
value
}
}
importStatus {
isRawFileMissing
}
createdAt
updatedAt
}
@@ -1739,8 +2017,10 @@ export const GetLibraryStatisticsDocument = `
query GetLibraryStatistics {
getLibraryStatistics {
totalDocuments
comicsMissingFiles
comicDirectorySize {
fileCount
totalSizeInGB
}
statistics {
fileTypes {
@@ -1874,6 +2154,7 @@ export const GetImportStatisticsDocument = `
totalLocalFiles
alreadyImported
newFiles
missingFiles
percentageImported
}
}

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

@@ -93,6 +93,9 @@ query GetRecentComics($limit: Int) {
value
}
}
importStatus {
isRawFileMissing
}
createdAt
updatedAt
}
@@ -176,8 +179,10 @@ query GetVolumeGroups {
query GetLibraryStatistics {
getLibraryStatistics {
totalDocuments
comicsMissingFiles
comicDirectorySize {
fileCount
totalSizeInGB
}
statistics {
fileTypes {

View File

@@ -6,6 +6,7 @@ query GetImportStatistics($directoryPath: String) {
totalLocalFiles
alreadyImported
newFiles
missingFiles
percentageImported
}
}

View File

@@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Track if we've received completion events
const completionEventReceived = useRef(false);
const queueDrainedEventReceived = useRef(false);
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
// Prevents a stale "running" DB session from showing as active on hard refresh.
const sessionStartedEventReceived = useRef(false);
// Query active import session - NO POLLING, only refetch on Socket.IO events
// Query active import session - polls every 3s as a fallback when a session is
// active (e.g. tab re-opened mid-import and socket events were missed)
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
{},
{
refetchOnWindowFocus: false,
refetchInterval: false, // NO POLLING
refetchOnWindowFocus: true,
refetchInterval: (query) => {
const s = (query.state.data as any)?.getActiveImportSession;
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
? 3000
: false;
},
}
);
@@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Case 3: Check if session is actually running/active
if (status === "running" || status === "active" || status === "processing") {
// Check if there's actual progress happening
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
// Only treat as active if there's progress OR it just started
if (hasProgress && hasQueuedWork) {
// Only treat as "just started" if the started event fired in this browser session.
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
// No in-session event AND no actual progress → stale unclosed session from a previous run.
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
const likelyStale = !sessionStartedEventReceived.current
&& stats.filesProcessed === 0
&& stats.filesSucceeded === 0;
if ((hasQueuedWork || justStarted) && !likelyStale) {
return {
status: "running",
sessionId,
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
isActive: true,
};
} else {
// Session says "running" but no progress - likely stuck/stale
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
// Session says "running" but all files processed — likely a stale session
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
return {
status: "idle",
sessionId: null,
@@ -243,10 +258,11 @@ export const useImportSessionStatus = (): ImportSessionState => {
};
const handleSessionStarted = () => {
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED event received");
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED / LS_INCREMENTAL_IMPORT_STARTED event received");
// Reset completion flags when new session starts
completionEventReceived.current = false;
queueDrainedEventReceived.current = false;
sessionStartedEventReceived.current = true;
refetch();
};
@@ -259,12 +275,14 @@ export const useImportSessionStatus = (): ImportSessionState => {
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
};
}, [getSocket, refetch]);

View File

@@ -44,21 +44,23 @@ export const determineCoverFile = (data): any => {
};
// comicvine
if (!isEmpty(data.comicvine)) {
coverFile.comicvine.url = data?.comicvine?.image.small_url;
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
coverFile.comicvine.issueName = data.comicvine?.name;
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
}
// rawFileDetails
if (!isEmpty(data.rawFileDetails)) {
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
);
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
coverFile.rawFile.issueName = data.rawFileDetails.name;
} else if (!isEmpty(data.rawFileDetails)) {
coverFile.rawFile.issueName = data.rawFileDetails.name;
}
// wanted
if (!isUndefined(data.locg)) {
if (!isNil(data.locg)) {
coverFile.locg.url = data.locg.cover;
coverFile.locg.issueName = data.locg.name;
coverFile.locg.publisher = data.locg.publisher;
@@ -66,14 +68,15 @@ export const determineCoverFile = (data): any => {
const result = filter(coverFile, (item) => item.url !== "");
if (result.length > 1) {
if (result.length >= 1) {
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
if (!isUndefined(highestPriorityCoverFile)) {
return highestPriorityCoverFile;
}
} else {
return result[0];
}
// No cover URL available — return rawFile entry so the name is still shown
return coverFile.rawFile;
};
export const determineExternalMetadata = (
@@ -85,8 +88,8 @@ export const determineExternalMetadata = (
case "comicvine":
return {
coverURL:
source.comicvine?.image.small_url ||
source.comicvine.volumeInformation?.image.small_url,
source.comicvine?.image?.small_url ||
source.comicvine?.volumeInformation?.image?.small_url,
issue: source.comicvine.name,
icon: "cvlogo.svg",
};

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: {
@@ -13,6 +13,8 @@ module.exports = {
scraped: "#b8edbc",
uncompressed: "#FFF3E0",
imported: "#d8dab0",
missing: "#fee2e2",
info: "#cdd9eb",
},
},
},
@@ -41,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,132 +0,0 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function iconifyPlugin() {
const iconCache = new Map();
const collections = new Map();
function loadCollection(prefix) {
if (collections.has(prefix)) {
return collections.get(prefix);
}
try {
const collectionPath = join(__dirname, 'node_modules', '@iconify-json', prefix, 'icons.json');
const data = JSON.parse(readFileSync(collectionPath, 'utf8'));
collections.set(prefix, data);
return data;
} catch (e) {
return null;
}
}
function getIconCSS(iconData, selector) {
const { body, width, height } = iconData;
const viewBox = `0 0 ${width || 24} ${height || 24}`;
// Create SVG data URI
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">${body}</svg>`;
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
return `${selector} {
display: inline-block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-image: url("${dataUri}");
mask-image: url("${dataUri}");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}`;
}
return {
name: 'vite-plugin-iconify',
transform(code, id) {
// Only process files that might contain icon classes
if (!id.endsWith('.tsx') && !id.endsWith('.jsx') && !id.endsWith('.ts') && !id.endsWith('.js')) {
return null;
}
// Find all icon-[...] patterns
const iconPattern = /icon-\[([^\]]+)\]/g;
const matches = [...code.matchAll(iconPattern)];
if (matches.length === 0) {
return null;
}
// Extract unique icons
const icons = new Set(matches.map(m => m[1]));
// Generate CSS for each icon
for (const iconName of icons) {
if (iconCache.has(iconName)) {
continue;
}
try {
// Parse icon name (e.g., "solar--add-square-bold-duotone")
const parts = iconName.split('--');
if (parts.length !== 2) continue;
const [prefix, name] = parts;
// Load collection
const collection = loadCollection(prefix);
if (!collection || !collection.icons || !collection.icons[name]) {
continue;
}
// Get icon data
const iconData = collection.icons[name];
// Generate CSS
const selector = `.icon-\\[${iconName}\\]`;
const iconCSS = getIconCSS(iconData, selector);
iconCache.set(iconName, iconCSS);
} catch (e) {
// Silently skip failed icons
}
}
return null;
},
resolveId(id) {
if (id === '/@iconify-css') {
return id;
}
},
load(id) {
if (id === '/@iconify-css') {
const allCSS = Array.from(iconCache.values()).join('\n');
return allCSS;
}
},
transformIndexHtml() {
// Inject icon CSS into HTML
const allCSS = Array.from(iconCache.values()).join('\n');
if (allCSS) {
return [
{
tag: 'style',
attrs: { type: 'text/css' },
children: allCSS,
injectTo: 'head'
}
];
}
}
};
}

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",
@@ -35,6 +35,9 @@ export default defineConfig({
},
},
},
resolve: {
dedupe: ["react", "react-dom"],
},
esbuild: {
supported: {
"top-level-await": true, //browsers can handle top-level-await features
@@ -42,6 +45,7 @@ export default defineConfig({
},
server: { host: true },
plugins: [
tailwindcss(),
react({
// Use React plugin in all *.jsx and *.tsx files
include: "**/*.{jsx,tsx}",

468
yarn.lock
View File

@@ -683,25 +683,25 @@
dependencies:
tslib "^2.0.0"
"@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1", "@emnapi/core@^1.8.1":
version "1.8.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
"@emnapi/core@^1.4.3", "@emnapi/core@^1.8.1":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034"
integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==
dependencies:
"@emnapi/wasi-threads" "1.1.0"
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.1", "@emnapi/runtime@^1.8.1":
version "1.8.1"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.8.1":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2"
integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
@@ -2397,12 +2397,10 @@
"@tybys/wasm-util" "^0.10.0"
"@napi-rs/wasm-runtime@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2"
integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==
version "1.1.2"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz#e25454b4d44cfabd21d1bc801705359870e33ecc"
integrity sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==
dependencies:
"@emnapi/core" "^1.7.1"
"@emnapi/runtime" "^1.7.1"
"@tybys/wasm-util" "^0.10.1"
"@nodelib/fs.scandir@2.1.5":
@@ -2530,6 +2528,135 @@
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
"@radix-ui/react-compose-refs@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
"@radix-ui/react-context@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
"@radix-ui/react-dialog@^1.1.1":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-dismissable-layer@1.1.11":
version "1.1.11"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37"
integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-escape-keydown" "1.1.1"
"@radix-ui/react-focus-guards@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f"
integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==
"@radix-ui/react-focus-scope@1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d"
integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-id@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-portal@1.1.9":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-presence@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-primitive@2.1.3":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"
integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==
dependencies:
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-slot@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-callback-ref@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40"
integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==
"@radix-ui/react-use-controllable-state@1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190"
integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==
dependencies:
"@radix-ui/react-use-effect-event" "0.0.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-effect-event@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907"
integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-escape-keydown@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29"
integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-layout-effect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@repeaterjs/repeater@^3.0.4", "@repeaterjs/repeater@^3.0.6":
version "3.0.6"
resolved "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz"
@@ -3004,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"
@@ -3061,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"
@@ -3089,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"
@@ -3100,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"
@@ -3380,13 +3614,20 @@
resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz"
integrity sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==
"@types/node@*", "@types/node@^25.3.0":
"@types/node@*":
version "25.3.0"
resolved "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz"
integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==
dependencies:
undici-types "~7.18.0"
"@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.19.0"
"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"
@@ -3846,6 +4087,13 @@ argparse@^2.0.1:
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.2.4:
version "1.2.6"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a"
integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==
dependencies:
tslib "^2.0.0"
aria-query@5.1.3:
version "5.1.3"
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz"
@@ -5038,6 +5286,11 @@ detect-newline@^3.1.0:
resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz"
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
@@ -6127,6 +6380,15 @@ fraction.js@^5.3.4:
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz"
integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==
framer-motion@^12.38.0:
version "12.38.0"
resolved "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz"
integrity sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==
dependencies:
motion-dom "^12.38.0"
motion-utils "^12.36.0"
tslib "^2.4.0"
fs-extra@^10.0.1:
version "10.1.0"
resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz"
@@ -6204,6 +6466,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
@@ -7630,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"
@@ -7699,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"
@@ -8096,6 +8437,26 @@ modern-tar@^0.7.3:
resolved "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz"
integrity sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==
motion-dom@^12.38.0:
version "12.38.0"
resolved "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz"
integrity sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==
dependencies:
motion-utils "^12.36.0"
motion-utils@^12.36.0:
version "12.36.0"
resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz"
integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==
motion@^12.38.0:
version "12.38.0"
resolved "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz"
integrity sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==
dependencies:
framer-motion "^12.38.0"
tslib "^2.4.0"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -8882,6 +9243,25 @@ react-refresh@^0.18.0:
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz"
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
react-remove-scroll-bar@^2.3.7:
version "2.3.8"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223"
integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==
dependencies:
react-style-singleton "^2.2.2"
tslib "^2.0.0"
react-remove-scroll@^2.6.3:
version "2.7.2"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz#6442da56791117661978ae99cd29be9026fecca0"
integrity sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==
dependencies:
react-remove-scroll-bar "^2.3.7"
react-style-singleton "^2.2.3"
tslib "^2.1.0"
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-router-dom@^7.13.1:
version "7.13.1"
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz"
@@ -8932,6 +9312,14 @@ react-sliding-pane@^7.3.0:
prop-types "^15.7.2"
react-modal "^3.14.3"
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==
dependencies:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-swipeable@^7.0.2:
version "7.0.2"
resolved "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz"
@@ -9832,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"
@@ -10134,10 +10527,10 @@ typescript@^4.3.2:
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.9.3:
version "5.9.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
ua-parser-js@^1.0.35:
version "1.0.41"
@@ -10184,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"
@@ -10265,6 +10663,13 @@ urlpattern-polyfill@^10.0.0:
resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz"
integrity sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==
use-callback-ref@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf"
integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==
dependencies:
tslib "^2.0.0"
use-composed-ref@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz"
@@ -10287,6 +10692,14 @@ use-latest@^1.2.1, use-latest@^1.3.0:
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-sidecar@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.6.0:
version "1.6.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
@@ -10336,6 +10749,13 @@ v8-to-istanbul@^9.0.1:
"@types/istanbul-lib-coverage" "^2.0.1"
convert-source-map "^2.0.0"
vaul@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"
integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
vite-plugin-html@^3.2.2:
version "3.2.2"
resolved "https://registry.npmjs.org/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz"