Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125c64418a |
@@ -1,379 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
2197
package-lock.json
generated
2197
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -13,8 +13,7 @@
|
|||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
|
"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",
|
"author": "Rishi Ghan",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
"@floating-ui/react-dom": "^2.1.7",
|
"@floating-ui/react-dom": "^2.1.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
"immer": "^11.1.4",
|
"immer": "^11.1.4",
|
||||||
"jsdoc": "^4.0.5",
|
"jsdoc": "^4.0.5",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"motion": "^12.38.0",
|
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
@@ -79,7 +77,6 @@
|
|||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"styled-components": "^6.3.11",
|
"styled-components": "^6.3.11",
|
||||||
"threetwo-ui-typings": "^1.0.14",
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"websocket": "^1.0.35",
|
"websocket": "^1.0.35",
|
||||||
@@ -112,7 +109,7 @@
|
|||||||
"@types/ellipsize": "^0.1.3",
|
"@types/ellipsize": "^0.1.3",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
@@ -141,7 +138,7 @@
|
|||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"tui-jsdoc-template": "^1.2.2",
|
"tui-jsdoc-template": "^1.2.2",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^5.9.3",
|
||||||
"wait-on": "^9.0.4"
|
"wait-on": "^9.0.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
|
|||||||
|
|
||||||
It is based on React 18, and uses:
|
It is based on React 18, and uses:
|
||||||
|
|
||||||
1. _zustand_ for state management
|
1. _Redux_ for state management
|
||||||
2. _socket.io_ for transferring data in real-time
|
2. _socket.io_ for transferring data in real-time
|
||||||
3. _React Router_ for routing
|
3. _React Router_ for routing
|
||||||
4. React DnD for drag-and-drop
|
4. React DnD for drag-and-drop
|
||||||
5. @tanstack/react-table for all tables
|
5. @tanstack/react-table for all tables
|
||||||
6. @tanstack/react-query for API calls
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, ReactElement, useCallback, useMemo } from "react";
|
import React, { useState, ReactElement, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import { RawFileDetails } from "./RawFileDetails";
|
import { RawFileDetails } from "./RawFileDetails";
|
||||||
@@ -10,7 +10,7 @@ import "react-sliding-pane/dist/react-sliding-pane.css";
|
|||||||
import SlidingPane from "react-sliding-pane";
|
import SlidingPane from "react-sliding-pane";
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import { styled } from "styled-components";
|
import { styled } from "styled-components";
|
||||||
import type { RawFileDetails as RawFileDetailsType, InferredMetadata } from "../../graphql/generated";
|
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||||
|
|
||||||
// Extracted modules
|
// Extracted modules
|
||||||
import { useComicVineMatching } from "./useComicVineMatching";
|
import { useComicVineMatching } from "./useComicVineMatching";
|
||||||
@@ -23,47 +23,57 @@ const StyledSlidingPanel = styled(SlidingPane)`
|
|||||||
background: #ccc;
|
background: #ccc;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ComicVineMetadata {
|
type InferredIssue = {
|
||||||
name?: string;
|
name?: string;
|
||||||
volumeInformation?: Record<string, unknown>;
|
number?: number;
|
||||||
[key: string]: unknown;
|
year?: string;
|
||||||
}
|
subtitle?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
interface Acquisition {
|
type ComicVineMetadata = {
|
||||||
|
name?: string;
|
||||||
|
volumeInformation?: any;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Acquisition = {
|
||||||
directconnect?: {
|
directconnect?: {
|
||||||
downloads?: unknown[];
|
downloads?: any[];
|
||||||
};
|
};
|
||||||
torrent?: unknown[];
|
torrent?: any[];
|
||||||
[key: string]: unknown;
|
[key: string]: any;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ComicDetailProps {
|
type ComicDetailProps = {
|
||||||
data: {
|
data: {
|
||||||
_id: string;
|
_id: string;
|
||||||
rawFileDetails?: RawFileDetailsType;
|
rawFileDetails?: RawFileDetailsType;
|
||||||
inferredMetadata: InferredMetadata;
|
inferredMetadata: {
|
||||||
|
issue?: InferredIssue;
|
||||||
|
};
|
||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicvine?: ComicVineMetadata;
|
comicvine?: ComicVineMetadata;
|
||||||
locg?: Record<string, unknown>;
|
locg?: any;
|
||||||
comicInfo?: Record<string, unknown>;
|
comicInfo?: any;
|
||||||
};
|
};
|
||||||
acquisition?: Acquisition;
|
acquisition?: Acquisition;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
userSettings?: Record<string, unknown>;
|
userSettings?: any;
|
||||||
queryClient?: unknown;
|
queryClient?: any;
|
||||||
comicObjectId?: string;
|
comicObjectId?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
* Component for displaying the metadata for a comic in greater detail.
|
||||||
* for metadata, archive operations, and acquisition.
|
|
||||||
*
|
*
|
||||||
* @param data.queryClient - react-query client passed through to the CV match
|
* @component
|
||||||
* panel so it can invalidate queries after a match is applied.
|
* @example
|
||||||
* @param data.comicObjectId - optional override for the comic ID; used when the
|
* return (
|
||||||
* component is rendered outside a route that provides the ID via `useParams`.
|
* <ComicDetail/>
|
||||||
|
* )
|
||||||
*/
|
*/
|
||||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
@@ -74,6 +84,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||||
acquisition,
|
acquisition,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
},
|
},
|
||||||
userSettings,
|
userSettings,
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -83,10 +94,24 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
const [modalIsOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
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
|
// Action event handlers
|
||||||
const openDrawerWithCVMatches = () => {
|
const openDrawerWithCVMatches = () => {
|
||||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||||
@@ -99,8 +124,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
setVisible(true);
|
setVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Hide "match on Comic Vine" when there are no raw file details — matching
|
// Action menu handler
|
||||||
// requires file metadata to seed the search query.
|
|
||||||
const Placeholder = components.Placeholder;
|
const Placeholder = components.Placeholder;
|
||||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||||
if (isUndefined(rawFileDetails)) {
|
if (isUndefined(rawFileDetails)) {
|
||||||
@@ -126,11 +150,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
const isComicBookMetadataAvailable =
|
const isComicBookMetadataAvailable =
|
||||||
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
||||||
|
|
||||||
const hasAnyMetadata =
|
|
||||||
isComicBookMetadataAvailable ||
|
|
||||||
!isEmpty(comicInfo) ||
|
|
||||||
!isNil(locg);
|
|
||||||
|
|
||||||
const areRawFileDetailsAvailable =
|
const areRawFileDetailsAvailable =
|
||||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||||
|
|
||||||
@@ -141,29 +160,26 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Query for airdc++
|
// Query for airdc++
|
||||||
const airDCPPQuery = useMemo(() => ({
|
const airDCPPQuery = {
|
||||||
issue: { name: issueName },
|
issue: {
|
||||||
}), [issueName]);
|
name: issueName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Create tab configuration
|
// Create tab configuration
|
||||||
const openReconcilePanel = useCallback(() => {
|
const tabGroup = createTabConfig({
|
||||||
setSlidingPanelContentId("metadataReconciliation");
|
|
||||||
setVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tabGroup = useMemo(() => createTabConfig({
|
|
||||||
data: data.data,
|
data: data.data,
|
||||||
hasAnyMetadata,
|
comicInfo,
|
||||||
|
isComicBookMetadataAvailable,
|
||||||
areRawFileDetailsAvailable,
|
areRawFileDetailsAvailable,
|
||||||
airDCPPQuery,
|
airDCPPQuery,
|
||||||
comicObjectId: _id,
|
comicObjectId: _id,
|
||||||
userSettings,
|
userSettings,
|
||||||
issueName,
|
issueName,
|
||||||
acquisition,
|
acquisition,
|
||||||
onReconcileMetadata: openReconcilePanel,
|
});
|
||||||
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
|
|
||||||
|
|
||||||
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
|
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||||
|
|
||||||
// Sliding panel content mapping
|
// Sliding panel content mapping
|
||||||
const renderSlidingPanelContent = () => {
|
const renderSlidingPanelContent = () => {
|
||||||
@@ -174,7 +190,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
rawFileDetails={rawFileDetails}
|
rawFileDetails={rawFileDetails}
|
||||||
inferredMetadata={inferredMetadata}
|
inferredMetadata={inferredMetadata}
|
||||||
comicVineMatches={comicVineMatches}
|
comicVineMatches={comicVineMatches}
|
||||||
// Prefer the route param; fall back to the data ID when rendered outside a route.
|
|
||||||
comicObjectId={comicObjectId || _id}
|
comicObjectId={comicObjectId || _id}
|
||||||
queryClient={queryClient}
|
queryClient={queryClient}
|
||||||
onMatchApplied={() => {
|
onMatchApplied={() => {
|
||||||
@@ -209,9 +224,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
<div className="grid">
|
<div className="grid">
|
||||||
<RawFileDetails
|
<RawFileDetails
|
||||||
data={{
|
data={{
|
||||||
rawFileDetails,
|
rawFileDetails: rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata: inferredMetadata,
|
||||||
createdAt,
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* action dropdown */}
|
{/* action dropdown */}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
interface ComicVineMatchPanelProps {
|
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||||
props: {
|
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.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(
|
const { comicvine } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
comicvine: state.comicvine,
|
comicvine: state.comicvine,
|
||||||
|
|||||||
@@ -1,41 +1,55 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
import { Form, Field, FieldRenderProps } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
|
||||||
interface EditMetadataPanelProps {
|
export const EditMetadataPanel = (props): ReactElement => {
|
||||||
data: {
|
const validate = async () => {};
|
||||||
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
mutators={{ ...arrayMutators }}
|
validate={validate}
|
||||||
|
mutators={{
|
||||||
|
...arrayMutators,
|
||||||
|
}}
|
||||||
render={({
|
render={({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
form: {
|
form: {
|
||||||
mutators: { push, pop },
|
mutators: { push, pop },
|
||||||
},
|
}, // injected from final-form-arrays above
|
||||||
|
pristine,
|
||||||
|
form,
|
||||||
|
submitting,
|
||||||
|
values,
|
||||||
}) => (
|
}) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Issue Name */}
|
{/* Issue Name */}
|
||||||
@@ -66,6 +80,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
<p className="text-xs">Do not enter the first zero</p>
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
{/* year */}
|
||||||
<div className="text-sm">Issue Year</div>
|
<div className="text-sm">Issue Year</div>
|
||||||
<Field
|
<Field
|
||||||
name="issue_year"
|
name="issue_year"
|
||||||
@@ -85,6 +100,8 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* page count */}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<label className="text-sm">Description</label>
|
<label className="text-sm">Description</label>
|
||||||
@@ -96,7 +113,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-label">
|
<div className="field-label">
|
||||||
@@ -136,7 +153,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
{/* Publisher */}
|
{/* Publisher */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -207,7 +224,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
{/* team credits */}
|
{/* team credits */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -285,6 +302,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { convert } from "html-to-text";
|
|||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
matchData: any;
|
matchData: any;
|
||||||
@@ -32,7 +31,7 @@ export const MatchResult = (props: MatchResultProps) => {
|
|||||||
// Invalidate and refetch the comic book metadata
|
// Invalidate and refetch the comic book metadata
|
||||||
if (props.queryClient) {
|
if (props.queryClient) {
|
||||||
await props.queryClient.invalidateQueries({
|
await props.queryClient.invalidateQueries({
|
||||||
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
|
queryKey: ["comicBookMetadata", comicObjectId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import React, { ReactElement, ReactNode } from "react";
|
import React, { ReactElement, ReactNode } from "react";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { format, parseISO, isValid } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import {
|
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||||
RawFileDetails as RawFileDetailsType,
|
|
||||||
InferredMetadata,
|
|
||||||
} from "../../graphql/generated";
|
|
||||||
|
|
||||||
type RawFileDetailsProps = {
|
type RawFileDetailsProps = {
|
||||||
data?: {
|
data?: {
|
||||||
rawFileDetails?: RawFileDetailsType;
|
rawFileDetails?: RawFileDetailsType;
|
||||||
inferredMetadata?: InferredMetadata;
|
inferredMetadata?: {
|
||||||
createdAt?: string;
|
issue?: {
|
||||||
|
year?: string;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
|
||||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||||
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||||
|
props.data || {};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-2xl ml-5">
|
<div className="max-w-2xl ml-5">
|
||||||
@@ -92,10 +97,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
|||||||
Import Details
|
Import Details
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||||
{createdAt && isValid(parseISO(createdAt)) ? (
|
{created_at ? (
|
||||||
<>
|
<>
|
||||||
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
|
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||||
{format(parseISO(createdAt), "h aaaa")}
|
{format(parseISO(created_at), "h aaaa")}
|
||||||
</>
|
</>
|
||||||
) : "N/A"}
|
) : "N/A"}
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import React from "react";
|
|||||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||||
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
|
import { RawFileDetails } from "../../graphql/generated";
|
||||||
|
|
||||||
interface CVMatchesPanelProps {
|
type InferredIssue = {
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
year?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CVMatchesPanelProps = {
|
||||||
rawFileDetails?: RawFileDetails;
|
rawFileDetails?: RawFileDetails;
|
||||||
inferredMetadata: InferredMetadata;
|
inferredMetadata: {
|
||||||
|
issue?: InferredIssue;
|
||||||
|
};
|
||||||
comicVineMatches: any[];
|
comicVineMatches: any[];
|
||||||
comicObjectId: string;
|
comicObjectId: string;
|
||||||
queryClient: any;
|
queryClient: any;
|
||||||
onMatchApplied: () => void;
|
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> = ({
|
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
@@ -62,4 +62,4 @@ type EditMetadataPanelWrapperProps = {
|
|||||||
|
|
||||||
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;
|
}) => <EditMetadataPanel data={rawFileDetails} />;
|
||||||
|
|||||||
@@ -47,12 +47,10 @@ export const TabControls = (props): ReactElement => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
<Suspense>
|
||||||
{filteredTabs.map(({ id, content }) => (
|
{filteredTabs.map(({ id, content }) => {
|
||||||
<React.Fragment key={id}>
|
return currentActive === id ? content : null;
|
||||||
{currentActive === id ? content : null}
|
})}
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,12 +131,10 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
if (isSuccess && shouldRefetchComicBookData) {
|
||||||
if (isSuccess && shouldRefetchComicBookData) {
|
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
setShouldRefetchComicBookData(false);
|
||||||
setShouldRefetchComicBookData(false);
|
}
|
||||||
}
|
|
||||||
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
|
|
||||||
|
|
||||||
// sliding panel init
|
// sliding panel init
|
||||||
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||||
|
|||||||
@@ -1,165 +1,15 @@
|
|||||||
import React, { ReactElement, useMemo, useState } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { isEmpty, isNil } from "lodash";
|
|
||||||
import { Drawer } from "vaul";
|
|
||||||
import ComicVineDetails from "../ComicVineDetails";
|
import ComicVineDetails from "../ComicVineDetails";
|
||||||
|
|
||||||
interface ComicVineMetadata {
|
export const VolumeInformation = (props): ReactElement => {
|
||||||
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 {
|
|
||||||
sourcedMetadata?: SourcedMetadata;
|
|
||||||
inferredMetadata?: { issue?: unknown };
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VolumeInformationProps {
|
|
||||||
data: VolumeInformationData;
|
|
||||||
onReconcile?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
}: {
|
|
||||||
sources: string[];
|
|
||||||
}): ReactElement => {
|
|
||||||
const [isSheetOpen, setSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-400">
|
|
||||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
|
||||||
{sources.length} metadata sources detected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row flex-wrap gap-2">
|
|
||||||
{sources.map((source) => (
|
|
||||||
<span
|
|
||||||
key={source}
|
|
||||||
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
|
||||||
/>
|
|
||||||
{SOURCE_LABELS[source] ?? source}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
|
||||||
onClick={() => setSheetOpen(true)}
|
|
||||||
>
|
|
||||||
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
|
||||||
Reconcile sources
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
|
|
||||||
<Drawer.Portal>
|
|
||||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
|
||||||
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
|
|
||||||
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
|
||||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
|
|
||||||
<div className="p-4">
|
|
||||||
{/* Reconciliation UI goes here */}
|
|
||||||
</div>
|
|
||||||
</Drawer.Content>
|
|
||||||
</Drawer.Portal>
|
|
||||||
</Drawer.Root>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 { data } = props;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div key={1}>
|
<div key={1}>
|
||||||
{presentSources.length > 1 && (
|
<ComicVineDetails
|
||||||
<MetadataSourceChips sources={presentSources} />
|
data={data.sourcedMetadata.comicvine}
|
||||||
)}
|
updatedAt={data.updatedAt}
|
||||||
{presentSources.length === 1 &&
|
/>
|
||||||
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
|
||||||
<ComicVineDetails
|
|
||||||
data={data.sourcedMetadata.comicvine}
|
|
||||||
updatedAt={data.updatedAt}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { lazy } from "react";
|
|||||||
import { isNil, isEmpty } from "lodash";
|
import { isNil, isEmpty } from "lodash";
|
||||||
|
|
||||||
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
|
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 ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
|
||||||
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
|
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
|
||||||
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
|
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
|
||||||
@@ -17,26 +18,26 @@ interface TabConfig {
|
|||||||
|
|
||||||
interface TabConfigParams {
|
interface TabConfigParams {
|
||||||
data: any;
|
data: any;
|
||||||
hasAnyMetadata: boolean;
|
comicInfo: any;
|
||||||
|
isComicBookMetadataAvailable: boolean;
|
||||||
areRawFileDetailsAvailable: boolean;
|
areRawFileDetailsAvailable: boolean;
|
||||||
airDCPPQuery: any;
|
airDCPPQuery: any;
|
||||||
comicObjectId: string;
|
comicObjectId: string;
|
||||||
userSettings: any;
|
userSettings: any;
|
||||||
issueName: string;
|
issueName: string;
|
||||||
acquisition?: any;
|
acquisition?: any;
|
||||||
onReconcileMetadata?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTabConfig = ({
|
export const createTabConfig = ({
|
||||||
data,
|
data,
|
||||||
hasAnyMetadata,
|
comicInfo,
|
||||||
|
isComicBookMetadataAvailable,
|
||||||
areRawFileDetailsAvailable,
|
areRawFileDetailsAvailable,
|
||||||
airDCPPQuery,
|
airDCPPQuery,
|
||||||
comicObjectId,
|
comicObjectId,
|
||||||
userSettings,
|
userSettings,
|
||||||
issueName,
|
issueName,
|
||||||
acquisition,
|
acquisition,
|
||||||
onReconcileMetadata,
|
|
||||||
}: TabConfigParams): TabConfig[] => {
|
}: TabConfigParams): TabConfig[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -45,10 +46,23 @@ export const createTabConfig = ({
|
|||||||
icon: (
|
icon: (
|
||||||
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||||
),
|
),
|
||||||
content: hasAnyMetadata ? (
|
content: isComicBookMetadataAvailable ? (
|
||||||
<VolumeInformation data={data} onReconcile={onReconcileMetadata} />
|
<VolumeInformation data={data} key={1} />
|
||||||
) : null,
|
) : null,
|
||||||
shouldShow: hasAnyMetadata,
|
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),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -56,7 +70,7 @@ export const createTabConfig = ({
|
|||||||
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
),
|
),
|
||||||
name: "Archive Operations",
|
name: "Archive Operations",
|
||||||
content: <ArchiveOperations data={data} />,
|
content: <ArchiveOperations data={data} key={3} />,
|
||||||
shouldShow: areRawFileDetailsAvailable,
|
shouldShow: areRawFileDetailsAvailable,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,6 +85,7 @@ export const createTabConfig = ({
|
|||||||
comicObjectId={comicObjectId}
|
comicObjectId={comicObjectId}
|
||||||
comicObject={data}
|
comicObject={data}
|
||||||
settings={userSettings}
|
settings={userSettings}
|
||||||
|
key={4}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
shouldShow: true,
|
shouldShow: true,
|
||||||
@@ -97,7 +112,7 @@ export const createTabConfig = ({
|
|||||||
),
|
),
|
||||||
content:
|
content:
|
||||||
!isNil(data) && !isEmpty(data) ? (
|
!isNil(data) && !isEmpty(data) ? (
|
||||||
<DownloadsPanel />
|
<DownloadsPanel key={5} />
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
<div className="column is-three-fifths">
|
||||||
<article className="message is-info">
|
<article className="message is-info">
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
import { isEmpty, isUndefined, map } from "lodash";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
|
||||||
|
|
||||||
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
type LibraryStatisticsProps = {
|
||||||
comicDirectorySize: DirectorySize;
|
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
|
||||||
comicsMissingFiles: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Props for {@link LibraryStatistics}. */
|
export const LibraryStatistics = (
|
||||||
interface LibraryStatisticsProps {
|
props: LibraryStatisticsProps,
|
||||||
stats: Stats | null | undefined;
|
): ReactElement => {
|
||||||
}
|
const { stats } = props;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Header
|
<Header
|
||||||
headerContent="Your Library In Numbers"
|
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"
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-row gap-5">
|
<div className="mt-3">
|
||||||
{/* Total records in database */}
|
<div className="flex flex-row gap-5">
|
||||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
<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">In database</dt>
|
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||||
{stats.totalDocuments} comics
|
{props.stats.totalDocuments} files
|
||||||
</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>
|
</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>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tagging coverage */}
|
<div className="">
|
||||||
<div className="flex flex-col gap-4">
|
{!isUndefined(props.stats.statistics) &&
|
||||||
{issues && issues.length > 0 && (
|
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
|
||||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
|
||||||
<span className="text-xl text-gray-700">{issues.length}</span>
|
return (
|
||||||
tagged with ComicVine
|
<span
|
||||||
</div>
|
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"
|
||||||
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
>
|
||||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
{fileType.data.length} {fileType.id}
|
||||||
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
|
</span>
|
||||||
with ComicInfo.xml
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const RecentlyImported = (
|
|||||||
sourcedMetadata,
|
sourcedMetadata,
|
||||||
canonicalMetadata,
|
canonicalMetadata,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
importStatus,
|
|
||||||
} = comic;
|
} = comic;
|
||||||
|
|
||||||
// Parse sourced metadata (GraphQL returns as strings)
|
// Parse sourced metadata (GraphQL returns as strings)
|
||||||
@@ -64,10 +63,7 @@ export const RecentlyImported = (
|
|||||||
!isUndefined(comicvine) &&
|
!isUndefined(comicvine) &&
|
||||||
!isUndefined(comicvine.volumeInformation);
|
!isUndefined(comicvine.volumeInformation);
|
||||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||||
const isMissingFile = importStatus?.isRawFileMissing === true;
|
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||||
const cardState = isMissingFile
|
|
||||||
? "missing"
|
|
||||||
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -131,6 +127,12 @@ export const RecentlyImported = (
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ type VolumeGroupsProps = {
|
|||||||
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
|
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
|
||||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||||
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToVolumes = (row: any) => {
|
const navigateToVolumes = (row: any) => {
|
||||||
navigate(`/volumes/all`);
|
navigate(`/volumes/all`);
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ type WantedComicsListProps = {
|
|||||||
|
|
||||||
export const WantedComicsList = ({
|
export const WantedComicsList = ({
|
||||||
comics,
|
comics,
|
||||||
}: WantedComicsListProps): ReactElement | null => {
|
}: WantedComicsListProps): ReactElement => {
|
||||||
if (!comics || comics.length === 0) return null;
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// embla carousel
|
// embla carousel
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
import {
|
||||||
|
useGetJobResultStatisticsQuery,
|
||||||
|
useGetImportStatisticsQuery,
|
||||||
|
useStartIncrementalImportMutation
|
||||||
|
} from "../../graphql/generated";
|
||||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
export const Import = (): ReactElement => {
|
interface ImportProps {
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
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 queryClient = useQueryClient();
|
||||||
|
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
@@ -20,6 +33,30 @@ export const Import = (): 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
|
// Force re-import mutation - re-imports all files regardless of import status
|
||||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -41,57 +78,101 @@ export const Import = (): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||||
|
|
||||||
const importSession = useImportSessionStatus();
|
// Get import statistics to determine if Start Import button should be shown
|
||||||
const hasActiveSession = importSession.isActive;
|
const { data: importStats } = useGetImportStatisticsQuery(
|
||||||
const wasComplete = useRef(false);
|
{},
|
||||||
|
{
|
||||||
// React to importSession.isComplete rather than socket events — more reliable
|
refetchOnWindowFocus: false,
|
||||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
refetchInterval: false,
|
||||||
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]);
|
);
|
||||||
|
|
||||||
|
// Use custom hook for definitive import session status tracking
|
||||||
|
// NO POLLING - relies on Socket.IO events only
|
||||||
|
const importSession = useImportSessionStatus();
|
||||||
|
|
||||||
|
const hasActiveSession = importSession.isActive;
|
||||||
|
|
||||||
|
// Determine if we should show the Start Import button
|
||||||
|
const hasNewFiles = importStats?.getImportStatistics?.success &&
|
||||||
|
importStats.getImportStatistics.stats &&
|
||||||
|
importStats.getImportStatistics.stats.newFiles > 0;
|
||||||
|
|
||||||
// Listen to socket events to update Past Imports table in real-time
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
|
const handleQueueDrained = () => refetch();
|
||||||
const handleImportCompleted = () => {
|
const handleCoverExtracted = () => refetch();
|
||||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
|
||||||
// Small delay to ensure backend has committed the job results
|
const handleSessionStarted = () => {
|
||||||
setTimeout(() => {
|
importJobQueue.setStatus("running");
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQueueDrained = () => {
|
const handleSessionCompleted = () => {
|
||||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
refetch();
|
||||||
// Small delay to ensure backend has committed the job results
|
importJobQueue.setStatus("drained");
|
||||||
setTimeout(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
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 () => {
|
return () => {
|
||||||
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
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, queryClient]);
|
}, [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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles force re-import - re-imports all files to fix indexing issues
|
* Handles force re-import - re-imports all files to fix indexing issues
|
||||||
@@ -116,6 +197,7 @@ export const Import = (): ReactElement => {
|
|||||||
disconnectSocket("/");
|
disconnectSocket("/");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
getSocket("/");
|
getSocket("/");
|
||||||
|
setSocketReconnectTrigger(prev => prev + 1);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
forceReImport();
|
forceReImport();
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -126,6 +208,54 @@ export const Import = (): 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
@@ -197,10 +327,53 @@ export const Import = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Force Re-Import Button - always shown when no import is running */}
|
{/* Active Session Warning */}
|
||||||
{!hasActiveSession &&
|
{hasActiveSession && !hasNewFiles && (
|
||||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
<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="my-6 max-w-screen-lg">
|
<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) && (
|
||||||
<button
|
<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"
|
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}
|
onClick={handleForceReImport}
|
||||||
@@ -214,8 +387,8 @@ export const Import = (): ReactElement => {
|
|||||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +1,93 @@
|
|||||||
import { ReactElement, useEffect, useState } from "react";
|
import React, { ReactElement, useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
useGetImportStatisticsQuery,
|
useGetImportStatisticsQuery,
|
||||||
useGetWantedComicsQuery,
|
useStartIncrementalImportMutation
|
||||||
useStartIncrementalImportMutation,
|
|
||||||
} from "../../graphql/generated";
|
} from "../../graphql/generated";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import statistics with card-based layout and progress bar.
|
* Import statistics with card-based layout and progress bar
|
||||||
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
* Updates in real-time via the useImportSessionStatus hook
|
||||||
* Also surfaces missing files detected by the file watcher.
|
|
||||||
*/
|
*/
|
||||||
export const RealTimeImportStats = (): ReactElement => {
|
export const RealTimeImportStats = (): ReactElement => {
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
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(
|
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
getSocket: state.getSocket,
|
getSocket: state.getSocket,
|
||||||
disconnectSocket: state.disconnectSocket,
|
disconnectSocket: state.disconnectSocket,
|
||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
// Get filesystem statistics (new files vs already imported)
|
||||||
|
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
|
||||||
{},
|
{},
|
||||||
{ refetchOnWindowFocus: false, refetchInterval: false },
|
{ refetchOnWindowFocus: false, refetchInterval: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const stats = importStats?.getImportStatistics?.stats;
|
// Get definitive import session status (handles Socket.IO events internally)
|
||||||
|
|
||||||
// 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 importSession = useImportSessionStatus();
|
||||||
|
|
||||||
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
|
||||||
useStartIncrementalImportMutation({
|
onSuccess: (data) => {
|
||||||
onSuccess: (data) => {
|
if (data.startIncrementalImport.success) {
|
||||||
if (data.startIncrementalImport.success) {
|
importJobQueue.setStatus("running");
|
||||||
importJobQueue.setStatus("running");
|
setImportError(null);
|
||||||
setImportError(null);
|
}
|
||||||
}
|
},
|
||||||
},
|
onError: (error: any) => {
|
||||||
onError: (error: any) => {
|
console.error("Failed to start import:", error);
|
||||||
setImportError(
|
setImportError(error?.message || "Failed to start import. Please try again.");
|
||||||
error?.message || "Failed to start import. Please try again.",
|
},
|
||||||
);
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const stats = importStats?.getImportStatistics?.stats;
|
||||||
const hasNewFiles = stats && stats.newFiles > 0;
|
const hasNewFiles = stats && stats.newFiles > 0;
|
||||||
const missingCount = stats?.missingFiles ?? 0;
|
|
||||||
|
|
||||||
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
// Refetch filesystem stats when import completes
|
||||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
useEffect(() => {
|
||||||
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
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
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
|
|
||||||
const handleStatsChange = () => {
|
const handleFilesystemChange = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
refetchStats();
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileDetected = (payload: { filePath: string }) => {
|
// File system changes that affect import statistics
|
||||||
handleStatsChange();
|
socket.on("LS_FILE_ADDED", handleFilesystemChange);
|
||||||
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
|
||||||
setDetectedFile(name);
|
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
|
||||||
setTimeout(() => setDetectedFile(null), 5000);
|
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
||||||
};
|
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
||||||
|
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
||||||
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 () => {
|
return () => {
|
||||||
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
socket.off("LS_FILE_ADDED", handleFilesystemChange);
|
||||||
socket.off("LS_FILES_MISSING", handleStatsChange);
|
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
|
||||||
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
|
||||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
||||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
||||||
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
||||||
};
|
};
|
||||||
}, [getSocket, queryClient]);
|
}, [getSocket, refetchStats]);
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
const handleStartImport = async () => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
|
// Check if import is already active using definitive status
|
||||||
if (importSession.isActive) {
|
if (importSession.isActive) {
|
||||||
setImportError(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,26 +112,24 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
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 isFirstImport = stats.alreadyImported === 0;
|
||||||
const buttonText = isFirstImport
|
const buttonText = isFirstImport
|
||||||
? `Start Import (${stats.newFiles} files)`
|
? `Start Import (${stats.newFiles} files)`
|
||||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||||
|
|
||||||
// Determine what to show in each card based on current phase
|
// Calculate display statistics
|
||||||
const sessionStats = importSession.stats;
|
const displayStats = importSession.isActive && importSession.stats
|
||||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
? {
|
||||||
|
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
|
||||||
const totalFiles = stats.totalLocalFiles;
|
filesQueued: importSession.stats.filesQueued,
|
||||||
const importedCount = stats.alreadyImported;
|
filesSucceeded: importSession.stats.filesSucceeded,
|
||||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
}
|
||||||
|
: {
|
||||||
const showProgressBar = socketImport !== null;
|
totalFiles: stats.totalLocalFiles,
|
||||||
const socketProgressPct =
|
filesQueued: stats.newFiles,
|
||||||
socketImport && socketImport.total > 0
|
filesSucceeded: stats.alreadyImported,
|
||||||
? Math.round((socketImport.completed / socketImport.total) * 100)
|
};
|
||||||
: 0;
|
|
||||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
|
||||||
const showMissingCard = missingCount > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -217,161 +141,89 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
<p className="font-semibold text-red-800 dark:text-red-300">Import Error</p>
|
||||||
Import Error
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{importError}</p>
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
{importError}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setImportError(null)}
|
onClick={() => setImportError(null)}
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||||
>
|
>
|
||||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File detected toast */}
|
{/* Import Button - only show when there are new files and no active import */}
|
||||||
{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 && (
|
{hasNewFiles && !importSession.isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStartImport}
|
onClick={handleStartImport}
|
||||||
disabled={isStartingImport}
|
disabled={isStartingImport}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
<span className="w-6 h-6">
|
||||||
|
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar — shown while importing and once complete */}
|
{/* Active Import Progress Bar */}
|
||||||
{showProgressBar && (
|
{importSession.isActive && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{socketImport!.active
|
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
|
||||||
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
|
||||||
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">
|
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{socketProgressPct}% complete
|
{Math.round(importSession.progress)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
||||||
style={{ width: `${socketProgressPct}%` }}
|
style={{ width: `${importSession.progress}%` }}
|
||||||
>
|
>
|
||||||
{socketImport!.active && (
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{/* Total files */}
|
{/* Files Detected Card */}
|
||||||
<div
|
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
|
||||||
className="rounded-lg p-6 text-center"
|
<div className="text-4xl font-bold text-white mb-2">
|
||||||
style={{ backgroundColor: "#6b7280" }}
|
{displayStats.totalFiles}
|
||||||
>
|
</div>
|
||||||
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
<div className="text-sm text-gray-200 font-medium">
|
||||||
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
files detected
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Imported */}
|
{/* To Import Card */}
|
||||||
<div
|
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
|
||||||
className="rounded-lg p-6 text-center"
|
<div className="text-4xl font-bold text-white mb-2">
|
||||||
style={{ backgroundColor: "#d8dab2" }}
|
{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' }}>
|
||||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
<div className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
{importedCount}
|
{displayStats.filesSucceeded}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700 font-medium">
|
<div className="text-sm text-gray-700 font-medium">
|
||||||
{importSession.isActive ? "imported so far" : "imported in database"}
|
already imported
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, ReactElement, useState } from "react";
|
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import PropTypes from "prop-types";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
@@ -11,130 +12,79 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, fromUnixTime, 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library page component. Displays a paginated, searchable table of all comics
|
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||||
* in the collection, with an optional filter for comics with missing raw files.
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* <Library />
|
||||||
*/
|
*/
|
||||||
export const Library = (): ReactElement => {
|
export const Library = (): ReactElement => {
|
||||||
const [searchParams] = useSearchParams();
|
// Default page state
|
||||||
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
// offset: 0
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
const [searchQuery, setSearchQuery] = useState({
|
||||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
|
||||||
query: {},
|
query: {},
|
||||||
pagination: { size: 25, from: 0 },
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: offset,
|
||||||
|
},
|
||||||
type: "all",
|
type: "all",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
/** Fetches a page of issues from the search API. */
|
/**
|
||||||
const fetchIssues = async (q: SearchQuery) => {
|
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||||
const { pagination, query, type } = q;
|
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||||
|
*/
|
||||||
|
const fetchIssues = async (searchQuery) => {
|
||||||
|
const { pagination, query, type } = searchQuery;
|
||||||
return await axios({
|
return await axios({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "http://localhost:3000/api/search/searchIssue",
|
url: "http://localhost:3000/api/search/searchIssue",
|
||||||
data: { query, pagination, type },
|
data: {
|
||||||
|
query,
|
||||||
|
pagination,
|
||||||
|
type,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, isPlaceholderData } = useQuery({
|
const searchIssues = (e) => {
|
||||||
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"] });
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
setSearchQuery({
|
setSearchQuery({
|
||||||
query: { volumeName: e.search },
|
query: {
|
||||||
pagination: { size: 15, from: 0 },
|
volumeName: e.search,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
type: "volumeName",
|
type: "volumeName",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Advances to the next page of results. */
|
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
queryKey: ["comics", offset, searchQuery],
|
||||||
if (!isPlaceholderData) {
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
placeholderData: keepPreviousData,
|
||||||
setSearchQuery({
|
});
|
||||||
query: {},
|
|
||||||
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
const searchResults = data?.data;
|
||||||
type: "all",
|
// Programmatically navigate to comic detail
|
||||||
trigger: "libraryPage",
|
const navigate = useNavigate();
|
||||||
});
|
const navigateToComicDetail = (row) => {
|
||||||
}
|
navigate(`/comic/details/${row.original._id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Goes back to the previous page of results. */
|
const ComicInfoXML = (value) => {
|
||||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
return value.data ? (
|
||||||
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">
|
<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="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">
|
<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>
|
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||||
@@ -144,6 +94,7 @@ export const Library = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
<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="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">
|
<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>
|
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||||
@@ -152,6 +103,7 @@ export const Library = (): ReactElement => {
|
|||||||
Pages: {value.data.pagecount[0]}
|
Pages: {value.data.pagecount[0]}
|
||||||
</span>
|
</span>
|
||||||
</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="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">
|
<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>
|
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||||
@@ -165,62 +117,30 @@ export const Library = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
) : null;
|
) : 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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: "Comic Metadata",
|
header: "Comic Metadata",
|
||||||
|
footer: 1,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "File Details",
|
header: "File Details",
|
||||||
id: "fileDetails",
|
id: "fileDetails",
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
accessorKey: "_source",
|
accessorKey: "_source",
|
||||||
cell: (info: any) => {
|
cell: (info) => {
|
||||||
const source = info.getValue();
|
return <MetadataPanel data={info.getValue()} />;
|
||||||
return (
|
|
||||||
<MetadataPanel
|
|
||||||
data={source}
|
|
||||||
isMissing={missingIdSet.has(info.row.original._id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "ComicInfo.xml",
|
header: "ComicInfo.xml",
|
||||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||||
cell: (info: any) =>
|
cell: (info) =>
|
||||||
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
!isEmpty(info.getValue()) ? (
|
||||||
|
<ComicInfoXML data={info.getValue()} />
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -230,30 +150,36 @@ export const Library = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Date of Import",
|
header: "Date of Import",
|
||||||
accessorKey: "_source.createdAt",
|
accessorKey: "_source.createdAt",
|
||||||
cell: (info: any) =>
|
cell: (info) => {
|
||||||
!isNil(info.getValue()) ? (
|
return !isNil(info.getValue()) ? (
|
||||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
<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")}
|
{format(parseISO(info.getValue()), "h aaaa")}
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Downloads",
|
header: "Downloads",
|
||||||
accessorKey: "_source.acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (info: any) => (
|
cell: (info) => (
|
||||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
<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 whitespace-nowrap">
|
<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="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
DC++: {info.getValue().directconnect.downloads.length}
|
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||||
|
DC++: {info.getValue().directconnect.downloads.length}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<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="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="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
Torrent: {info.getValue().torrent.length}
|
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||||
|
Torrent: {info.getValue().torrent.length}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -261,99 +187,129 @@ export const Library = (): ReactElement => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[missingIdSet],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const FilterDropdown = () => (
|
/**
|
||||||
<div className="relative">
|
* Pagination control that fetches the next x (pageSize) items
|
||||||
<select
|
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||||
value={activeFilter}
|
* @param {number} pageIndex
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
* @param {number} pageSize
|
||||||
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"
|
* @returns void
|
||||||
>
|
*
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
**/
|
||||||
<option key={opt.value} value={opt.value}>
|
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||||
{opt.label}
|
if (!isPlaceholderData) {
|
||||||
</option>
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
))}
|
setSearchQuery({
|
||||||
</select>
|
query: {},
|
||||||
<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>
|
pagination: {
|
||||||
</div>
|
size: 15,
|
||||||
);
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// setOffset(pageSize * pageIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isMissingFilter = activeFilter === "missingFiles";
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<section>
|
<div>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<section>
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<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="text-center sm:text-left">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
<div className="text-center sm:text-left">
|
||||||
Library
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
</h1>
|
Library
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
</h1>
|
||||||
Browse your comic book collection.
|
|
||||||
</p>
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse your comic book collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</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">
|
||||||
{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>
|
<div>
|
||||||
<p>
|
<T2Table
|
||||||
No comics were found in the library, Elasticsearch reports no indices. Try
|
totalPages={searchResults.hits.total.value}
|
||||||
importing a few comics into the library and come back.
|
columns={columns}
|
||||||
</p>
|
sourceData={searchResults?.hits.hits}
|
||||||
|
rowClickHandler={navigateToComicDetail}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||||
|
</T2Table>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
<FilterDropdown />
|
) : (
|
||||||
</div>
|
<div className="mx-auto max-w-screen-xl mt-5">
|
||||||
)}
|
<article
|
||||||
</section>
|
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>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ICardProps {
|
|||||||
children?: PropTypes.ReactNodeLike;
|
children?: PropTypes.ReactNodeLike;
|
||||||
borderColorClass?: string;
|
borderColorClass?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
|
||||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
cardContainerStyle?: React.CSSProperties;
|
cardContainerStyle?: React.CSSProperties;
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
@@ -28,8 +28,6 @@ const getCardStateClass = (cardState?: string): string => {
|
|||||||
return "bg-card-uncompressed";
|
return "bg-card-uncompressed";
|
||||||
case "imported":
|
case "imported":
|
||||||
return "bg-card-imported";
|
return "bg-card-imported";
|
||||||
case "missing":
|
|
||||||
return "bg-card-missing";
|
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -104,22 +102,11 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
case "vertical-2":
|
case "vertical-2":
|
||||||
return (
|
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"}`}>
|
<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"}`}>
|
||||||
<div className="relative">
|
<img
|
||||||
{props.imageUrl ? (
|
alt="Home"
|
||||||
<img
|
src={props.imageUrl}
|
||||||
alt="Home"
|
className="rounded-t-md object-cover"
|
||||||
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 ? (
|
{props.title ? (
|
||||||
<div className="px-3 pt-3 mb-2">
|
<div className="px-3 pt-3 mb-2">
|
||||||
|
|||||||
@@ -8,17 +8,14 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
|||||||
import { find, isUndefined } from "lodash";
|
import { find, isUndefined } from "lodash";
|
||||||
|
|
||||||
interface IMetadatPanelProps {
|
interface IMetadatPanelProps {
|
||||||
data: any;
|
value: any;
|
||||||
value?: any;
|
children: any;
|
||||||
children?: any;
|
imageStyle: any;
|
||||||
imageStyle?: any;
|
titleStyle: any;
|
||||||
titleStyle?: any;
|
tagsStyle: any;
|
||||||
tagsStyle?: any;
|
containerStyle: any;
|
||||||
containerStyle?: any;
|
|
||||||
isMissing?: boolean;
|
|
||||||
}
|
}
|
||||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||||
const { isMissing = false } = props;
|
|
||||||
const {
|
const {
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
@@ -34,10 +31,8 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
{
|
{
|
||||||
name: "rawFileDetails",
|
name: "rawFileDetails",
|
||||||
content: () => (
|
content: () => (
|
||||||
<dl
|
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
|
||||||
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>
|
||||||
>
|
|
||||||
<dt className="flex items-center gap-2">
|
|
||||||
<p className="text-sm sm:text-lg">{issueName}</p>
|
<p className="text-sm sm:text-lg">{issueName}</p>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-xs sm:text-sm">
|
<dd className="text-xs sm:text-sm">
|
||||||
@@ -63,35 +58,26 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
||||||
{/* File extension */}
|
{/* File extension */}
|
||||||
{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="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">
|
||||||
<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>
|
||||||
<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>
|
||||||
)}
|
|
||||||
|
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{rawFileDetails.mimeType}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* size */}
|
{/* size */}
|
||||||
{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="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">
|
||||||
<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>
|
||||||
<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>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Missing file Icon */}
|
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||||
{isMissing && (
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
<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>
|
||||||
)}
|
</span>
|
||||||
|
|
||||||
{/* Uncompressed version available? */}
|
{/* Uncompressed version available? */}
|
||||||
{rawFileDetails.archive?.uncompressed && (
|
{rawFileDetails.archive?.uncompressed && (
|
||||||
@@ -191,6 +177,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
const metadataPanel = find(metadataContentPanel, {
|
const metadataPanel = find(metadataContentPanel, {
|
||||||
name: objectReference,
|
name: objectReference,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
<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">
|
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
||||||
@@ -201,7 +188,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
imageStyle={props.imageStyle}
|
imageStyle={props.imageStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">{metadataPanel?.content()}</div>
|
<div className="flex-1">{metadataPanel.content()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,89 +1,69 @@
|
|||||||
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
import React, { ReactElement, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
Row,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
/** Props for {@link T2Table}. */
|
interface T2TableProps {
|
||||||
interface T2TableProps<TData> {
|
sourceData?: unknown[];
|
||||||
/** Row data to render. */
|
|
||||||
sourceData?: TData[];
|
|
||||||
/** Total number of records across all pages, used for pagination display. */
|
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
/** Column definitions (TanStack Table {@link ColumnDef} array). */
|
columns?: unknown[];
|
||||||
columns?: ColumnDef<TData>[];
|
|
||||||
/** Callbacks for navigating between pages. */
|
|
||||||
paginationHandlers?: {
|
paginationHandlers?: {
|
||||||
nextPage?(pageIndex: number, pageSize: number): void;
|
nextPage?(...args: unknown[]): unknown;
|
||||||
previousPage?(pageIndex: number, pageSize: number): void;
|
previousPage?(...args: unknown[]): unknown;
|
||||||
};
|
};
|
||||||
/** Called with the TanStack row object when a row is clicked. */
|
rowClickHandler?(...args: unknown[]): unknown;
|
||||||
rowClickHandler?(row: Row<TData>): void;
|
children?: any;
|
||||||
/** 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 => {
|
||||||
* A paginated data table with a two-row sticky header.
|
const {
|
||||||
*
|
sourceData,
|
||||||
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
|
columns,
|
||||||
* element placed immediately before the table. The second header row's `top`
|
paginationHandlers: { nextPage, previousPage },
|
||||||
* offset is measured at mount time so both rows stay flush regardless of font
|
totalPages,
|
||||||
* size or padding changes.
|
rowClickHandler,
|
||||||
*/
|
} = tableOptions;
|
||||||
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>({
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 1,
|
pageIndex: 1,
|
||||||
pageSize: 15,
|
pageSize: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
/** Advances to the next page and notifies the parent. */
|
/**
|
||||||
|
* Pagination control to move forward one page
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
const goToNextPage = () => {
|
const goToNextPage = () => {
|
||||||
setPagination({ pageIndex: pageIndex + 1, pageSize });
|
setPagination({
|
||||||
nextPage?.(pageIndex, pageSize);
|
pageIndex: pageIndex + 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
nextPage(pageIndex, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Goes back one page and notifies the parent. */
|
/**
|
||||||
|
* Pagination control to move backward one page
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
const goToPreviousPage = () => {
|
const goToPreviousPage = () => {
|
||||||
setPagination({ pageIndex: pageIndex - 1, pageSize });
|
setPagination({
|
||||||
previousPage?.(pageIndex, pageSize);
|
pageIndex: pageIndex - 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
previousPage(pageIndex, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -92,62 +72,63 @@ export const T2Table = <TData,>({
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
pageCount: sourceData.length ?? -1,
|
pageCount: sourceData.length ?? -1,
|
||||||
state: { pagination },
|
state: {
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-fit">
|
<div className="container max-w-fit">
|
||||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
<div>
|
||||||
{children}
|
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||||
|
{/* Search bar */}
|
||||||
|
{tableOptions.children}
|
||||||
|
|
||||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
{/* Pagination controls */}
|
||||||
<div className="mb-1">
|
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
<div className="mb-1">
|
||||||
</div>
|
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
</div>
|
||||||
{totalPages} comics in all
|
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||||
</p>
|
{totalPages} comics in all
|
||||||
<div className="inline-flex flex-row mt-3">
|
</p>
|
||||||
<button
|
<div className="inline-flex flex-row mt-3">
|
||||||
onClick={goToPreviousPage}
|
<button
|
||||||
disabled={pageIndex === 1}
|
onClick={() => goToPreviousPage()}
|
||||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
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>
|
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
|
||||||
<button
|
</button>
|
||||||
onClick={goToNextPage}
|
<button
|
||||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||||
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" />
|
>
|
||||||
</button>
|
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={sentinelRef} />
|
|
||||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
||||||
<thead>
|
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900">
|
||||||
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
||||||
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header, index) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
colSpan={header.colSpan}
|
colSpan={header.colSpan}
|
||||||
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
|
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"
|
||||||
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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -155,11 +136,11 @@ export const T2Table = <TData,>({
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row, rowIndex) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => rowClickHandler?.(row)}
|
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"}`}
|
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"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-3 py-2 align-top">
|
<td key={cell.id} className="px-3 py-2 align-top">
|
||||||
|
|||||||
@@ -28,23 +28,6 @@ export type AcquisitionSourceInput = {
|
|||||||
wanted?: InputMaybe<Scalars['Boolean']['input']>;
|
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 = {
|
export type Archive = {
|
||||||
__typename?: 'Archive';
|
__typename?: 'Archive';
|
||||||
expandedPath?: Maybe<Scalars['String']['output']>;
|
expandedPath?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -69,26 +52,6 @@ export type AutoMergeSettingsInput = {
|
|||||||
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
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 = {
|
export type CanonicalMetadata = {
|
||||||
__typename?: 'CanonicalMetadata';
|
__typename?: 'CanonicalMetadata';
|
||||||
ageRating?: Maybe<MetadataField>;
|
ageRating?: Maybe<MetadataField>;
|
||||||
@@ -160,11 +123,6 @@ export type ComicConnection = {
|
|||||||
totalCount: Scalars['Int']['output'];
|
totalCount: Scalars['Int']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ComicVineMatchInput = {
|
|
||||||
volume: ComicVineVolumeRefInput;
|
|
||||||
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ComicVineResourceResponse = {
|
export type ComicVineResourceResponse = {
|
||||||
__typename?: 'ComicVineResourceResponse';
|
__typename?: 'ComicVineResourceResponse';
|
||||||
error: Scalars['String']['output'];
|
error: Scalars['String']['output'];
|
||||||
@@ -185,24 +143,6 @@ export type ComicVineSearchResult = {
|
|||||||
offset: Scalars['Int']['output'];
|
offset: Scalars['Int']['output'];
|
||||||
results: Array<SearchResultItem>;
|
results: Array<SearchResultItem>;
|
||||||
status_code: Scalars['Int']['output'];
|
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 {
|
export enum ConflictResolutionStrategy {
|
||||||
@@ -237,22 +177,10 @@ export type DirectConnectBundleInput = {
|
|||||||
size?: InputMaybe<Scalars['String']['input']>;
|
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 = {
|
export type DirectConnectInput = {
|
||||||
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
|
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DirectConnectSettings = {
|
|
||||||
__typename?: 'DirectConnectSettings';
|
|
||||||
client?: Maybe<DirectConnectClient>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DirectorySize = {
|
export type DirectorySize = {
|
||||||
__typename?: 'DirectorySize';
|
__typename?: 'DirectorySize';
|
||||||
fileCount: Scalars['Int']['output'];
|
fileCount: Scalars['Int']['output'];
|
||||||
@@ -306,37 +234,6 @@ export type GetVolumesInput = {
|
|||||||
volumeURI: Scalars['String']['input'];
|
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 = {
|
export type ImageUrls = {
|
||||||
__typename?: 'ImageUrls';
|
__typename?: 'ImageUrls';
|
||||||
icon_url?: Maybe<Scalars['String']['output']>;
|
icon_url?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -406,7 +303,6 @@ export type ImportStatistics = {
|
|||||||
export type ImportStats = {
|
export type ImportStats = {
|
||||||
__typename?: 'ImportStats';
|
__typename?: 'ImportStats';
|
||||||
alreadyImported: Scalars['Int']['output'];
|
alreadyImported: Scalars['Int']['output'];
|
||||||
missingFiles: Scalars['Int']['output'];
|
|
||||||
newFiles: Scalars['Int']['output'];
|
newFiles: Scalars['Int']['output'];
|
||||||
percentageImported: Scalars['String']['output'];
|
percentageImported: Scalars['String']['output'];
|
||||||
totalLocalFiles: Scalars['Int']['output'];
|
totalLocalFiles: Scalars['Int']['output'];
|
||||||
@@ -415,7 +311,6 @@ export type ImportStats = {
|
|||||||
export type ImportStatus = {
|
export type ImportStatus = {
|
||||||
__typename?: 'ImportStatus';
|
__typename?: 'ImportStatus';
|
||||||
isImported?: Maybe<Scalars['Boolean']['output']>;
|
isImported?: Maybe<Scalars['Boolean']['output']>;
|
||||||
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
|
|
||||||
matchedResult?: Maybe<MatchedResult>;
|
matchedResult?: Maybe<MatchedResult>;
|
||||||
tagged?: Maybe<Scalars['Boolean']['output']>;
|
tagged?: Maybe<Scalars['Boolean']['output']>;
|
||||||
};
|
};
|
||||||
@@ -526,7 +421,6 @@ export type LocgMetadataInput = {
|
|||||||
export type LibraryStatistics = {
|
export type LibraryStatistics = {
|
||||||
__typename?: 'LibraryStatistics';
|
__typename?: 'LibraryStatistics';
|
||||||
comicDirectorySize: DirectorySize;
|
comicDirectorySize: DirectorySize;
|
||||||
comicsMissingFiles: Scalars['Int']['output'];
|
|
||||||
statistics: Array<StatisticsFacet>;
|
statistics: Array<StatisticsFacet>;
|
||||||
totalDocuments: Scalars['Int']['output'];
|
totalDocuments: Scalars['Int']['output'];
|
||||||
};
|
};
|
||||||
@@ -621,10 +515,6 @@ export type Mutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
/** Placeholder for future mutations */
|
/** Placeholder for future mutations */
|
||||||
_empty?: Maybe<Scalars['String']['output']>;
|
_empty?: Maybe<Scalars['String']['output']>;
|
||||||
/** Add a torrent to qBittorrent */
|
|
||||||
addTorrent?: Maybe<AddTorrentResult>;
|
|
||||||
analyzeImage: ImageAnalysisResult;
|
|
||||||
applyComicVineMatch: Comic;
|
|
||||||
bulkResolveMetadata: Array<Comic>;
|
bulkResolveMetadata: Array<Comic>;
|
||||||
forceCompleteSession: ForceCompleteResult;
|
forceCompleteSession: ForceCompleteResult;
|
||||||
importComic: ImportComicResult;
|
importComic: ImportComicResult;
|
||||||
@@ -634,28 +524,11 @@ export type Mutation = {
|
|||||||
setMetadataField: Comic;
|
setMetadataField: Comic;
|
||||||
startIncrementalImport: IncrementalImportResult;
|
startIncrementalImport: IncrementalImportResult;
|
||||||
startNewImport: ImportJobResult;
|
startNewImport: ImportJobResult;
|
||||||
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
|
|
||||||
updateSourcedMetadata: Comic;
|
updateSourcedMetadata: Comic;
|
||||||
updateUserPreferences: UserPreferences;
|
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 = {
|
export type MutationBulkResolveMetadataArgs = {
|
||||||
comicIds: Array<Scalars['ID']['input']>;
|
comicIds: Array<Scalars['ID']['input']>;
|
||||||
};
|
};
|
||||||
@@ -706,13 +579,6 @@ export type MutationStartNewImportArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUncompressArchiveArgs = {
|
|
||||||
comicObjectId: Scalars['ID']['input'];
|
|
||||||
filePath: Scalars['String']['input'];
|
|
||||||
options?: InputMaybe<Scalars['JSON']['input']>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpdateSourcedMetadataArgs = {
|
export type MutationUpdateSourcedMetadataArgs = {
|
||||||
comicId: Scalars['ID']['input'];
|
comicId: Scalars['ID']['input'];
|
||||||
metadata: Scalars['String']['input'];
|
metadata: Scalars['String']['input'];
|
||||||
@@ -761,17 +627,6 @@ export type Provenance = {
|
|||||||
url?: Maybe<Scalars['String']['output']>;
|
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 = {
|
export type Publisher = {
|
||||||
__typename?: 'Publisher';
|
__typename?: 'Publisher';
|
||||||
api_detail_url?: Maybe<Scalars['String']['output']>;
|
api_detail_url?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -787,9 +642,7 @@ export type PublisherStats = {
|
|||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
_empty?: Maybe<Scalars['String']['output']>;
|
|
||||||
analyzeMetadataConflicts: Array<MetadataConflict>;
|
analyzeMetadataConflicts: Array<MetadataConflict>;
|
||||||
bundles: Array<Bundle>;
|
|
||||||
comic?: Maybe<Comic>;
|
comic?: Maybe<Comic>;
|
||||||
comics: ComicConnection;
|
comics: ComicConnection;
|
||||||
/** Fetch resource from Metron API */
|
/** Fetch resource from Metron API */
|
||||||
@@ -810,18 +663,13 @@ export type Query = {
|
|||||||
getVolume: VolumeDetailResponse;
|
getVolume: VolumeDetailResponse;
|
||||||
/** Get weekly pull list from League of Comic Geeks */
|
/** Get weekly pull list from League of Comic Geeks */
|
||||||
getWeeklyPullList: MetadataPullListResponse;
|
getWeeklyPullList: MetadataPullListResponse;
|
||||||
hubs: Array<Hub>;
|
|
||||||
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
|
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
|
||||||
/** Search ComicVine for volumes, issues, characters, etc. */
|
/** Search ComicVine for volumes, issues, characters, etc. */
|
||||||
searchComicVine: ComicVineSearchResult;
|
searchComicVine: ComicVineSearchResult;
|
||||||
searchIssue: SearchIssueResult;
|
searchIssue: SearchIssueResult;
|
||||||
searchTorrents: Array<TorrentSearchResult>;
|
|
||||||
settings?: Maybe<AppSettings>;
|
|
||||||
torrentJobs?: Maybe<TorrentJob>;
|
|
||||||
userPreferences?: Maybe<UserPreferences>;
|
userPreferences?: Maybe<UserPreferences>;
|
||||||
/** Advanced volume-based search with scoring and filtering */
|
/** Advanced volume-based search with scoring and filtering */
|
||||||
volumeBasedSearch: VolumeBasedSearchResponse;
|
volumeBasedSearch: VolumeBasedSearchResponse;
|
||||||
walkFolders: Array<Scalars['String']['output']>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -830,12 +678,6 @@ export type QueryAnalyzeMetadataConflictsArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryBundlesArgs = {
|
|
||||||
comicObjectId: Scalars['ID']['input'];
|
|
||||||
config?: InputMaybe<Scalars['JSON']['input']>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type QueryComicArgs = {
|
export type QueryComicArgs = {
|
||||||
id: Scalars['ID']['input'];
|
id: Scalars['ID']['input'];
|
||||||
};
|
};
|
||||||
@@ -891,11 +733,6 @@ export type QueryGetWeeklyPullListArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryHubsArgs = {
|
|
||||||
host: HostInput;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type QueryPreviewCanonicalMetadataArgs = {
|
export type QueryPreviewCanonicalMetadataArgs = {
|
||||||
comicId: Scalars['ID']['input'];
|
comicId: Scalars['ID']['input'];
|
||||||
preferences?: InputMaybe<UserPreferencesInput>;
|
preferences?: InputMaybe<UserPreferencesInput>;
|
||||||
@@ -914,21 +751,6 @@ 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 = {
|
export type QueryUserPreferencesArgs = {
|
||||||
userId?: InputMaybe<Scalars['String']['input']>;
|
userId?: InputMaybe<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
@@ -938,12 +760,6 @@ export type QueryVolumeBasedSearchArgs = {
|
|||||||
input: VolumeSearchInput;
|
input: VolumeSearchInput;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryWalkFoldersArgs = {
|
|
||||||
basePathToWalk: Scalars['String']['input'];
|
|
||||||
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RawFileDetails = {
|
export type RawFileDetails = {
|
||||||
__typename?: 'RawFileDetails';
|
__typename?: 'RawFileDetails';
|
||||||
archive?: Maybe<Archive>;
|
archive?: Maybe<Archive>;
|
||||||
@@ -1122,24 +938,6 @@ export type TeamCredit = {
|
|||||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
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 UserPreferences = {
|
export type UserPreferences = {
|
||||||
__typename?: 'UserPreferences';
|
__typename?: 'UserPreferences';
|
||||||
autoMerge: AutoMergeSettings;
|
autoMerge: AutoMergeSettings;
|
||||||
@@ -1285,7 +1083,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, importStatus?: { __typename?: 'ImportStatus', isRawFileMissing?: boolean | 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 }> } };
|
||||||
|
|
||||||
export type GetWantedComicsQueryVariables = Exact<{
|
export type GetWantedComicsQueryVariables = Exact<{
|
||||||
paginationOptions: PaginationOptionsInput;
|
paginationOptions: PaginationOptionsInput;
|
||||||
@@ -1303,7 +1101,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
|
|||||||
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
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 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 GetWeeklyPullListQueryVariables = Exact<{
|
export type GetWeeklyPullListQueryVariables = Exact<{
|
||||||
input: WeeklyPullListInput;
|
input: WeeklyPullListInput;
|
||||||
@@ -1317,7 +1115,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, missingFiles: number, percentageImported: string } } };
|
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 StartNewImportMutationVariables = Exact<{
|
export type StartNewImportMutationVariables = Exact<{
|
||||||
sessionId: Scalars['String']['input'];
|
sessionId: Scalars['String']['input'];
|
||||||
@@ -1726,9 +1524,6 @@ export const GetRecentComicsDocument = `
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
importStatus {
|
|
||||||
isRawFileMissing
|
|
||||||
}
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -1944,10 +1739,8 @@ export const GetLibraryStatisticsDocument = `
|
|||||||
query GetLibraryStatistics {
|
query GetLibraryStatistics {
|
||||||
getLibraryStatistics {
|
getLibraryStatistics {
|
||||||
totalDocuments
|
totalDocuments
|
||||||
comicsMissingFiles
|
|
||||||
comicDirectorySize {
|
comicDirectorySize {
|
||||||
fileCount
|
fileCount
|
||||||
totalSizeInGB
|
|
||||||
}
|
}
|
||||||
statistics {
|
statistics {
|
||||||
fileTypes {
|
fileTypes {
|
||||||
@@ -2081,7 +1874,6 @@ export const GetImportStatisticsDocument = `
|
|||||||
totalLocalFiles
|
totalLocalFiles
|
||||||
alreadyImported
|
alreadyImported
|
||||||
newFiles
|
newFiles
|
||||||
missingFiles
|
|
||||||
percentageImported
|
percentageImported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,6 @@ query GetRecentComics($limit: Int) {
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
importStatus {
|
|
||||||
isRawFileMissing
|
|
||||||
}
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -179,10 +176,8 @@ query GetVolumeGroups {
|
|||||||
query GetLibraryStatistics {
|
query GetLibraryStatistics {
|
||||||
getLibraryStatistics {
|
getLibraryStatistics {
|
||||||
totalDocuments
|
totalDocuments
|
||||||
comicsMissingFiles
|
|
||||||
comicDirectorySize {
|
comicDirectorySize {
|
||||||
fileCount
|
fileCount
|
||||||
totalSizeInGB
|
|
||||||
}
|
}
|
||||||
statistics {
|
statistics {
|
||||||
fileTypes {
|
fileTypes {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ query GetImportStatistics($directoryPath: String) {
|
|||||||
totalLocalFiles
|
totalLocalFiles
|
||||||
alreadyImported
|
alreadyImported
|
||||||
newFiles
|
newFiles
|
||||||
missingFiles
|
|
||||||
percentageImported
|
percentageImported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,22 +60,13 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
// Track if we've received completion events
|
// Track if we've received completion events
|
||||||
const completionEventReceived = useRef(false);
|
const completionEventReceived = useRef(false);
|
||||||
const queueDrainedEventReceived = 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 - polls every 3s as a fallback when a session is
|
// Query active import session - NO POLLING, only refetch on Socket.IO events
|
||||||
// active (e.g. tab re-opened mid-import and socket events were missed)
|
|
||||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: false, // NO POLLING
|
||||||
const s = (query.state.data as any)?.getActiveImportSession;
|
|
||||||
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
|
||||||
? 3000
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,18 +152,12 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
|
|
||||||
// Case 3: Check if session is actually running/active
|
// Case 3: Check if session is actually running/active
|
||||||
if (status === "running" || status === "active" || status === "processing") {
|
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;
|
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
||||||
// 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.
|
// Only treat as active if there's progress OR it just started
|
||||||
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
if (hasProgress && hasQueuedWork) {
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
status: "running",
|
status: "running",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -187,8 +172,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Session says "running" but all files processed — likely a stale session
|
// Session says "running" but no progress - likely stuck/stale
|
||||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
|
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||||
return {
|
return {
|
||||||
status: "idle",
|
status: "idle",
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -258,11 +243,10 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionStarted = () => {
|
const handleSessionStarted = () => {
|
||||||
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED / LS_INCREMENTAL_IMPORT_STARTED event received");
|
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED event received");
|
||||||
// Reset completion flags when new session starts
|
// Reset completion flags when new session starts
|
||||||
completionEventReceived.current = false;
|
completionEventReceived.current = false;
|
||||||
queueDrainedEventReceived.current = false;
|
queueDrainedEventReceived.current = false;
|
||||||
sessionStartedEventReceived.current = true;
|
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,14 +259,12 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
|
||||||
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
|
||||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||||
};
|
};
|
||||||
}, [getSocket, refetch]);
|
}, [getSocket, refetch]);
|
||||||
|
|||||||
@@ -44,23 +44,21 @@ export const determineCoverFile = (data): any => {
|
|||||||
};
|
};
|
||||||
// comicvine
|
// comicvine
|
||||||
if (!isEmpty(data.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.issueName = data.comicvine?.name;
|
||||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||||
}
|
}
|
||||||
// rawFileDetails
|
// rawFileDetails
|
||||||
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
|
if (!isEmpty(data.rawFileDetails)) {
|
||||||
const encodedFilePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||||
);
|
);
|
||||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||||
} else if (!isEmpty(data.rawFileDetails)) {
|
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
|
||||||
}
|
}
|
||||||
// wanted
|
// wanted
|
||||||
|
|
||||||
if (!isNil(data.locg)) {
|
if (!isUndefined(data.locg)) {
|
||||||
coverFile.locg.url = data.locg.cover;
|
coverFile.locg.url = data.locg.cover;
|
||||||
coverFile.locg.issueName = data.locg.name;
|
coverFile.locg.issueName = data.locg.name;
|
||||||
coverFile.locg.publisher = data.locg.publisher;
|
coverFile.locg.publisher = data.locg.publisher;
|
||||||
@@ -68,15 +66,14 @@ export const determineCoverFile = (data): any => {
|
|||||||
|
|
||||||
const result = filter(coverFile, (item) => item.url !== "");
|
const result = filter(coverFile, (item) => item.url !== "");
|
||||||
|
|
||||||
if (result.length >= 1) {
|
if (result.length > 1) {
|
||||||
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
||||||
if (!isUndefined(highestPriorityCoverFile)) {
|
if (!isUndefined(highestPriorityCoverFile)) {
|
||||||
return 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 = (
|
export const determineExternalMetadata = (
|
||||||
@@ -88,8 +85,8 @@ export const determineExternalMetadata = (
|
|||||||
case "comicvine":
|
case "comicvine":
|
||||||
return {
|
return {
|
||||||
coverURL:
|
coverURL:
|
||||||
source.comicvine?.image?.small_url ||
|
source.comicvine?.image.small_url ||
|
||||||
source.comicvine?.volumeInformation?.image?.small_url,
|
source.comicvine.volumeInformation?.image.small_url,
|
||||||
issue: source.comicvine.name,
|
issue: source.comicvine.name,
|
||||||
icon: "cvlogo.svg",
|
icon: "cvlogo.svg",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ module.exports = {
|
|||||||
scraped: "#b8edbc",
|
scraped: "#b8edbc",
|
||||||
uncompressed: "#FFF3E0",
|
uncompressed: "#FFF3E0",
|
||||||
imported: "#d8dab0",
|
imported: "#d8dab0",
|
||||||
missing: "#fee2e2",
|
|
||||||
info: "#cdd9eb",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
132
vite-plugin-iconify.js
Normal file
132
vite-plugin-iconify.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,9 +35,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
|
||||||
dedupe: ["react", "react-dom"],
|
|
||||||
},
|
|
||||||
esbuild: {
|
esbuild: {
|
||||||
supported: {
|
supported: {
|
||||||
"top-level-await": true, //browsers can handle top-level-await features
|
"top-level-await": true, //browsers can handle top-level-await features
|
||||||
|
|||||||
291
yarn.lock
291
yarn.lock
@@ -683,25 +683,25 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@emnapi/core@^1.4.3", "@emnapi/core@^1.8.1":
|
"@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1", "@emnapi/core@^1.8.1":
|
||||||
version "1.9.2"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034"
|
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
|
||||||
integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==
|
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/wasi-threads" "1.2.1"
|
"@emnapi/wasi-threads" "1.1.0"
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.8.1":
|
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.1", "@emnapi/runtime@^1.8.1":
|
||||||
version "1.9.2"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2"
|
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
|
||||||
integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==
|
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
|
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0":
|
||||||
version "1.2.1"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
|
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
|
||||||
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
|
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
@@ -2397,10 +2397,12 @@
|
|||||||
"@tybys/wasm-util" "^0.10.0"
|
"@tybys/wasm-util" "^0.10.0"
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime@^1.1.1":
|
"@napi-rs/wasm-runtime@^1.1.1":
|
||||||
version "1.1.2"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz#e25454b4d44cfabd21d1bc801705359870e33ecc"
|
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2"
|
||||||
integrity sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==
|
integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@emnapi/core" "^1.7.1"
|
||||||
|
"@emnapi/runtime" "^1.7.1"
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
"@tybys/wasm-util" "^0.10.1"
|
||||||
|
|
||||||
"@nodelib/fs.scandir@2.1.5":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
@@ -2528,135 +2530,6 @@
|
|||||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
|
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
|
||||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
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":
|
"@repeaterjs/repeater@^3.0.4", "@repeaterjs/repeater@^3.0.6":
|
||||||
version "3.0.6"
|
version "3.0.6"
|
||||||
resolved "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz"
|
resolved "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz"
|
||||||
@@ -3507,20 +3380,13 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz"
|
||||||
integrity sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==
|
integrity sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*", "@types/node@^25.3.0":
|
||||||
version "25.3.0"
|
version "25.3.0"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz"
|
resolved "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz"
|
||||||
integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==
|
integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~7.18.0"
|
undici-types "~7.18.0"
|
||||||
|
|
||||||
"@types/node@^25.5.2":
|
|
||||||
version "25.5.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.2.tgz#94861e32f9ffd8de10b52bbec403465c84fff762"
|
|
||||||
integrity sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==
|
|
||||||
dependencies:
|
|
||||||
undici-types "~7.18.0"
|
|
||||||
|
|
||||||
"@types/parse-json@^4.0.0":
|
"@types/parse-json@^4.0.0":
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"
|
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"
|
||||||
@@ -3980,13 +3846,6 @@ argparse@^2.0.1:
|
|||||||
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
|
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
|
||||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
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:
|
aria-query@5.1.3:
|
||||||
version "5.1.3"
|
version "5.1.3"
|
||||||
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz"
|
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz"
|
||||||
@@ -5179,11 +5038,6 @@ detect-newline@^3.1.0:
|
|||||||
resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz"
|
||||||
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
|
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:
|
dir-glob@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
|
||||||
@@ -6273,15 +6127,6 @@ fraction.js@^5.3.4:
|
|||||||
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz"
|
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz"
|
||||||
integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==
|
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:
|
fs-extra@^10.0.1:
|
||||||
version "10.1.0"
|
version "10.1.0"
|
||||||
resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz"
|
resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz"
|
||||||
@@ -6359,11 +6204,6 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@
|
|||||||
hasown "^2.0.2"
|
hasown "^2.0.2"
|
||||||
math-intrinsics "^1.1.0"
|
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:
|
get-package-type@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
|
resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz"
|
||||||
@@ -8256,26 +8096,6 @@ modern-tar@^0.7.3:
|
|||||||
resolved "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz"
|
resolved "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz"
|
||||||
integrity sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==
|
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:
|
ms@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
|
||||||
@@ -9062,25 +8882,6 @@ react-refresh@^0.18.0:
|
|||||||
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz"
|
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz"
|
||||||
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
|
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:
|
react-router-dom@^7.13.1:
|
||||||
version "7.13.1"
|
version "7.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz"
|
||||||
@@ -9131,14 +8932,6 @@ react-sliding-pane@^7.3.0:
|
|||||||
prop-types "^15.7.2"
|
prop-types "^15.7.2"
|
||||||
react-modal "^3.14.3"
|
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:
|
react-swipeable@^7.0.2:
|
||||||
version "7.0.2"
|
version "7.0.2"
|
||||||
resolved "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz"
|
resolved "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz"
|
||||||
@@ -9462,10 +9255,10 @@ sass@^1.97.3:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@parcel/watcher" "^2.4.1"
|
"@parcel/watcher" "^2.4.1"
|
||||||
|
|
||||||
sax@^1.4.1:
|
sax@^1.5.0:
|
||||||
version "1.4.4"
|
version "1.5.0"
|
||||||
resolved "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.5.0.tgz#b5549b671069b7aa392df55ec7574cf411179eb8"
|
||||||
integrity sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==
|
integrity sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==
|
||||||
|
|
||||||
saxes@^6.0.0:
|
saxes@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
@@ -9985,9 +9778,9 @@ supports-preserve-symlinks-flag@^1.0.0:
|
|||||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
svgo@^4.0.0:
|
svgo@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.1"
|
||||||
resolved "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz"
|
resolved "https://registry.yarnpkg.com/svgo/-/svgo-4.0.1.tgz#c82dacd04ee9f1d55cd4e0b7f9a214c86670e3ee"
|
||||||
integrity sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==
|
integrity sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==
|
||||||
dependencies:
|
dependencies:
|
||||||
commander "^11.1.0"
|
commander "^11.1.0"
|
||||||
css-select "^5.1.0"
|
css-select "^5.1.0"
|
||||||
@@ -9995,7 +9788,7 @@ svgo@^4.0.0:
|
|||||||
css-what "^6.1.0"
|
css-what "^6.1.0"
|
||||||
csso "^5.0.5"
|
csso "^5.0.5"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
sax "^1.4.1"
|
sax "^1.5.0"
|
||||||
|
|
||||||
swap-case@^2.0.2:
|
swap-case@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
@@ -10341,10 +10134,10 @@ typescript@^4.3.2:
|
|||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
|
||||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||||
|
|
||||||
typescript@^6.0.2:
|
typescript@^5.9.3:
|
||||||
version "6.0.2"
|
version "5.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
|
||||||
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
|
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||||
|
|
||||||
ua-parser-js@^1.0.35:
|
ua-parser-js@^1.0.35:
|
||||||
version "1.0.41"
|
version "1.0.41"
|
||||||
@@ -10472,13 +10265,6 @@ urlpattern-polyfill@^10.0.0:
|
|||||||
resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz"
|
resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz"
|
||||||
integrity sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==
|
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:
|
use-composed-ref@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz"
|
resolved "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz"
|
||||||
@@ -10501,14 +10287,6 @@ use-latest@^1.2.1, use-latest@^1.3.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
use-isomorphic-layout-effect "^1.1.1"
|
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:
|
use-sync-external-store@^1.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
|
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
|
||||||
@@ -10558,13 +10336,6 @@ v8-to-istanbul@^9.0.1:
|
|||||||
"@types/istanbul-lib-coverage" "^2.0.1"
|
"@types/istanbul-lib-coverage" "^2.0.1"
|
||||||
convert-source-map "^2.0.0"
|
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:
|
vite-plugin-html@^3.2.2:
|
||||||
version "3.2.2"
|
version "3.2.2"
|
||||||
resolved "https://registry.npmjs.org/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz"
|
resolved "https://registry.npmjs.org/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user