Compare commits
89 Commits
comicvine-
...
4e53f23e79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e53f23e79 | ||
|
|
91e99c50d9 | ||
| 733a453352 | |||
| 3d88920f39 | |||
| 0949ebc637 | |||
| 3e045f4c10 | |||
| 17db1e64e1 | |||
| d7ab553120 | |||
| 91592019c4 | |||
| 0e8f63101c | |||
| 4e2cad790b | |||
| ba1b5bb965 | |||
| 8546641152 | |||
| 867935be39 | |||
| d506cf8ba8 | |||
| 71d7034d01 | |||
| a217d447fa | |||
| 20336e5569 | |||
| 8913e9cd99 | |||
| c392333170 | |||
| e083c18c0e | |||
| 7818c6f290 | |||
| 46e683859e | |||
| a45eae2604 | |||
| a0d971e010 | |||
| aec989d021 | |||
| 2b4ee716e3 | |||
| ec52906eca | |||
| 07f5e6efe6 | |||
| 5d18bd1e43 | |||
| 74c0d6513c | |||
| 4b8d7b5905 | |||
| 92992449a9 | |||
| 59afeded6a | |||
| f9aac5e19f | |||
| a8ae4130a6 | |||
| 6f781af381 | |||
| c005d118ac | |||
| 4498830e29 | |||
| e113066094 | |||
| 0af9482be9 | |||
| 37a2d0c75b | |||
| b47b38cc8d | |||
|
|
5c99cfb28b | ||
|
|
880b6c44ff | ||
| b0c8c295c7 | |||
| d39e2cdca1 | |||
|
|
1a6154e0b4 | ||
|
|
d8e110e2e7 | ||
|
|
9189386593 | ||
| a10cb07d67 | |||
|
|
2a83855115 | ||
|
|
58b60a38b4 | ||
|
|
c339dc9df1 | ||
|
|
d8bcad88b7 | ||
|
|
ff1742998a | ||
|
|
de1fb349f6 | ||
|
|
698e2a89da | ||
|
|
0597180637 | ||
|
|
422b16abf8 | ||
| 80f0ced0b0 | |||
|
|
0def39cd73 | ||
|
|
1c60837ec9 | ||
|
|
d42a700cec | ||
|
|
40fd8ede5d | ||
|
|
24b09c9c5e | ||
| c176dab78b | |||
| c0b189c9e6 | |||
| f3333b5c2c | |||
| 2ce90d94c0 | |||
| 0e445ba3d4 | |||
| 9c5fb93d5b | |||
| 338a46224d | |||
|
|
0615d08e7d | ||
| 21a127509b | |||
|
|
e92b86792e | ||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 |
379
.claude/skills/jsdoc/SKILL.md
Normal file
379
.claude/skills/jsdoc/SKILL.md
Normal file
@@ -0,0 +1,379 @@
|
||||
---
|
||||
name: jsdoc
|
||||
description: Commenting and documentation guidelines. Auto-activate when the user discusses comments, documentation, docstrings, code clarity, API docs, JSDoc, or asks about commenting strategies.
|
||||
---
|
||||
|
||||
Auto-activate when: User discusses comments, documentation, docstrings, code clarity, code quality, API docs, JSDoc, Python docstrings, or asks about commenting strategies.
|
||||
Core Principle
|
||||
|
||||
Write code that speaks for itself. Comment only when necessary to explain WHY, not WHAT.
|
||||
|
||||
Most code does not need comments. Well-written code with clear naming and structure is self-documenting.
|
||||
|
||||
The best comment is the one you don't need to write because the code is already obvious.
|
||||
The Commenting Philosophy
|
||||
When to Comment
|
||||
|
||||
✅ DO comment when explaining:
|
||||
|
||||
WHY something is done (business logic, design decisions)
|
||||
Complex algorithms and their reasoning
|
||||
Non-obvious trade-offs or constraints
|
||||
Workarounds for bugs or limitations
|
||||
API contracts and public interfaces
|
||||
Regex patterns and what they match
|
||||
Performance considerations or optimizations
|
||||
Constants and magic numbers
|
||||
Gotchas or surprising behaviors
|
||||
|
||||
❌ DON'T comment when:
|
||||
|
||||
The code is obvious and self-explanatory
|
||||
The comment repeats the code (redundant)
|
||||
Better naming would eliminate the need
|
||||
The comment would become outdated quickly
|
||||
It's decorative or organizational noise
|
||||
It states what a standard language construct does
|
||||
|
||||
Comment Anti-Patterns
|
||||
❌ 1. Obvious Comments
|
||||
|
||||
BAD:
|
||||
|
||||
counter = 0 # Initialize counter to zero
|
||||
counter += 1 # Increment counter by one
|
||||
user_name = input("Enter name: ") # Get user name from input
|
||||
|
||||
Better: No comment needed - the code is self-explanatory.
|
||||
❌ 2. Redundant Comments
|
||||
|
||||
BAD:
|
||||
|
||||
def get_user_name(user):
|
||||
return user.name # Return the user's name
|
||||
|
||||
def calculate_total(items):
|
||||
# Loop through items and sum the prices
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item.price
|
||||
return total
|
||||
|
||||
Better:
|
||||
|
||||
def get_user_name(user):
|
||||
return user.name
|
||||
|
||||
def calculate_total(items):
|
||||
return sum(item.price for item in items)
|
||||
|
||||
❌ 3. Outdated Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Calculate tax at 5% rate
|
||||
tax = price * 0.08 # Actually 8%, comment is wrong
|
||||
|
||||
# DEPRECATED: Use new_api_function() instead
|
||||
def old_function(): # Still being used, comment is misleading
|
||||
pass
|
||||
|
||||
Better: Keep comments in sync with code, or remove them entirely.
|
||||
❌ 4. Noise Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Start of function
|
||||
def calculate():
|
||||
# Declare variable
|
||||
result = 0
|
||||
# Return result
|
||||
return result
|
||||
# End of function
|
||||
|
||||
Better: Remove all of these comments.
|
||||
❌ 5. Dead Code & Changelog Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Don't comment out code - use version control
|
||||
# def old_function():
|
||||
# return "deprecated"
|
||||
|
||||
# Don't maintain history in comments
|
||||
# Modified by John on 2023-01-15
|
||||
# Fixed bug reported by Sarah on 2023-02-03
|
||||
|
||||
Better: Delete the code. Git has the history.
|
||||
Good Comment Examples
|
||||
✅ Complex Business Logic
|
||||
|
||||
# Apply progressive tax brackets: 10% up to $10k, 20% above
|
||||
# This matches IRS publication 501 for 2024
|
||||
def calculate_progressive_tax(income):
|
||||
if income <= 10000:
|
||||
return income * 0.10
|
||||
else:
|
||||
return 1000 + (income - 10000) * 0.20
|
||||
|
||||
✅ Non-obvious Algorithms
|
||||
|
||||
# Using Floyd-Warshall for all-pairs shortest paths
|
||||
# because we need distances between all nodes.
|
||||
# Time: O(n³), Space: O(n²)
|
||||
for k in range(vertices):
|
||||
for i in range(vertices):
|
||||
for j in range(vertices):
|
||||
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
|
||||
|
||||
✅ Regex Patterns
|
||||
|
||||
# Match email format: username@domain.extension
|
||||
# Allows letters, numbers, dots, hyphens in username
|
||||
# Requires valid domain and 2+ char extension
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
|
||||
✅ API Constraints or Gotchas
|
||||
|
||||
# GitHub API rate limit: 5000 requests/hour for authenticated users
|
||||
# We implement exponential backoff to handle rate limiting
|
||||
await rate_limiter.wait()
|
||||
response = await fetch(github_api_url)
|
||||
|
||||
✅ Workarounds for Bugs
|
||||
|
||||
# HACK: Workaround for bug in library v2.1.0
|
||||
# Remove after upgrading to v2.2.0
|
||||
# See: https://github.com/library/issues/123
|
||||
if library_version == "2.1.0":
|
||||
apply_workaround()
|
||||
|
||||
Decision Framework
|
||||
|
||||
Before writing a comment, ask yourself:
|
||||
Step 1: Is the code self-explanatory?
|
||||
|
||||
If YES → No comment needed
|
||||
If NO → Continue to step 2
|
||||
|
||||
Step 2: Would a better variable/function name eliminate the need?
|
||||
|
||||
If YES → Refactor the code instead
|
||||
If NO → Continue to step 3
|
||||
|
||||
Step 3: Does this explain WHY, not WHAT?
|
||||
|
||||
If explaining WHAT → Refactor code to be clearer
|
||||
If explaining WHY → Good comment candidate
|
||||
|
||||
Step 4: Will this help future maintainers?
|
||||
|
||||
If YES → Write the comment
|
||||
If NO → Skip it
|
||||
|
||||
Special Cases for Comments
|
||||
Public APIs and Docstrings
|
||||
Python Docstrings
|
||||
|
||||
def calculate_compound_interest(
|
||||
principal: float,
|
||||
rate: float,
|
||||
time: int,
|
||||
compound_frequency: int = 1
|
||||
) -> float:
|
||||
"""
|
||||
Calculate compound interest using the standard formula.
|
||||
|
||||
Args:
|
||||
principal: Initial amount invested
|
||||
rate: Annual interest rate as decimal (e.g., 0.05 for 5%)
|
||||
time: Time period in years
|
||||
compound_frequency: Times per year interest compounds (default: 1)
|
||||
|
||||
Returns:
|
||||
Final amount after compound interest
|
||||
|
||||
Raises:
|
||||
ValueError: If any parameter is negative
|
||||
|
||||
Example:
|
||||
>>> calculate_compound_interest(1000, 0.05, 10)
|
||||
1628.89
|
||||
"""
|
||||
if principal < 0 or rate < 0 or time < 0:
|
||||
raise ValueError("Parameters must be non-negative")
|
||||
|
||||
# Compound interest formula: A = P(1 + r/n)^(nt)
|
||||
return principal * (1 + rate / compound_frequency) ** (compound_frequency * time)
|
||||
|
||||
JavaScript/TypeScript JSDoc
|
||||
|
||||
/**
|
||||
* Fetch user data from the API.
|
||||
*
|
||||
* @param {string} userId - The unique user identifier
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.includeProfile - Include profile data (default: true)
|
||||
* @param {number} options.timeout - Request timeout in ms (default: 5000)
|
||||
*
|
||||
* @returns {Promise<User>} User object with requested fields
|
||||
*
|
||||
* @throws {Error} If userId is invalid or request fails
|
||||
*
|
||||
* @example
|
||||
* const user = await fetchUser('123', { includeProfile: true });
|
||||
*/
|
||||
async function fetchUser(userId, options = {}) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
Constants and Configuration
|
||||
|
||||
# Based on network reliability studies (95th percentile)
|
||||
MAX_RETRIES = 3
|
||||
|
||||
# AWS Lambda timeout is 15s, leaving 5s buffer for cleanup
|
||||
API_TIMEOUT = 10000 # milliseconds
|
||||
|
||||
# Cache duration optimized for balance between freshness and load
|
||||
# See: docs/performance-tuning.md
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
Annotations for TODOs and Warnings
|
||||
|
||||
# TODO: Replace with proper authentication after security review
|
||||
# Issue: #456
|
||||
def temporary_auth(user):
|
||||
return True
|
||||
|
||||
# WARNING: This function modifies the original array instead of creating a copy
|
||||
def sort_in_place(arr):
|
||||
arr.sort()
|
||||
return arr
|
||||
|
||||
# FIXME: Memory leak in production - investigate connection pooling
|
||||
# Ticket: JIRA-789
|
||||
def get_connection():
|
||||
return create_connection()
|
||||
|
||||
# PERF: Consider caching this result if called frequently in hot path
|
||||
def expensive_calculation(data):
|
||||
return complex_algorithm(data)
|
||||
|
||||
# SECURITY: Validate input to prevent SQL injection before using in query
|
||||
def build_query(user_input):
|
||||
sanitized = escape_sql(user_input)
|
||||
return f"SELECT * FROM users WHERE name = '{sanitized}'"
|
||||
|
||||
Common Annotation Keywords
|
||||
|
||||
TODO: - Work that needs to be done
|
||||
FIXME: - Known bugs that need fixing
|
||||
HACK: - Temporary workarounds
|
||||
NOTE: - Important information or context
|
||||
WARNING: - Critical information about usage
|
||||
PERF: - Performance considerations
|
||||
SECURITY: - Security-related notes
|
||||
BUG: - Known bug documentation
|
||||
REFACTOR: - Code that needs refactoring
|
||||
DEPRECATED: - Soon-to-be-removed code
|
||||
|
||||
Refactoring Over Commenting
|
||||
Instead of Commenting Complex Code...
|
||||
|
||||
BAD: Complex code with comment
|
||||
|
||||
# Check if user is admin or has special permissions
|
||||
if user.role == "admin" or (user.permissions and "special" in user.permissions):
|
||||
grant_access()
|
||||
|
||||
...Extract to Named Function
|
||||
|
||||
GOOD: Self-explanatory through naming
|
||||
|
||||
def user_has_admin_access(user):
|
||||
return user.role == "admin" or has_special_permission(user)
|
||||
|
||||
def has_special_permission(user):
|
||||
return user.permissions and "special" in user.permissions
|
||||
|
||||
if user_has_admin_access(user):
|
||||
grant_access()
|
||||
|
||||
Language-Specific Examples
|
||||
JavaScript
|
||||
|
||||
// Good: Explains WHY we debounce
|
||||
// Debounce search to reduce API calls (500ms wait after last keystroke)
|
||||
const debouncedSearch = debounce(searchAPI, 500);
|
||||
|
||||
// Bad: Obvious
|
||||
let count = 0; // Initialize count to zero
|
||||
count++; // Increment count
|
||||
|
||||
// Good: Explains algorithm choice
|
||||
// Using Set for O(1) lookup instead of Array.includes() which is O(n)
|
||||
const seen = new Set(ids);
|
||||
|
||||
Python
|
||||
|
||||
# Good: Explains the algorithm choice
|
||||
# Using binary search because data is sorted and we need O(log n) performance
|
||||
index = bisect.bisect_left(sorted_list, target)
|
||||
|
||||
# Bad: Redundant
|
||||
def get_total(items):
|
||||
return sum(items) # Return the sum of items
|
||||
|
||||
# Good: Explains why we're doing this
|
||||
# Extract to separate function for type checking in mypy
|
||||
def validate_user(user):
|
||||
if not user or not user.id:
|
||||
raise ValueError("Invalid user")
|
||||
return user
|
||||
|
||||
TypeScript
|
||||
|
||||
// Good: Explains the type assertion
|
||||
// TypeScript can't infer this is never null after the check
|
||||
const element = document.getElementById('app') as HTMLElement;
|
||||
|
||||
// Bad: Obvious
|
||||
const sum = a + b; // Add a and b
|
||||
|
||||
// Good: Explains non-obvious behavior
|
||||
// spread operator creates shallow copy; use JSON for deep copy
|
||||
const newConfig = { ...config };
|
||||
|
||||
Comment Quality Checklist
|
||||
|
||||
Before committing, ensure your comments:
|
||||
|
||||
Explain WHY, not WHAT
|
||||
Are grammatically correct and clear
|
||||
Will remain accurate as code evolves
|
||||
Add genuine value to code understanding
|
||||
Are placed appropriately (above the code they describe)
|
||||
Use proper spelling and professional language
|
||||
Follow team conventions for annotation keywords
|
||||
Could not be replaced by better naming or structure
|
||||
Are not obvious statements about language features
|
||||
Reference tickets/issues when applicable
|
||||
|
||||
Summary
|
||||
|
||||
Priority order:
|
||||
|
||||
Clear code - Self-explanatory through naming and structure
|
||||
Good comments - Explain WHY when necessary
|
||||
Documentation - API docs, docstrings for public interfaces
|
||||
No comments - Better than bad comments that lie or clutter
|
||||
|
||||
Remember: Comments are a failure to make the code self-explanatory. Use them sparingly and wisely.
|
||||
Key Takeaways
|
||||
Goal Approach
|
||||
Reduce comments Improve naming, extract functions, simplify logic
|
||||
Improve clarity Use self-explanatory code structure, clear variable names
|
||||
Document APIs Use docstrings/JSDoc for public interfaces
|
||||
Explain WHY Comment only business logic, algorithms, workarounds
|
||||
Maintain accuracy Update comments when code changes, or remove them
|
||||
353
.claude/skills/typescript/SKILL.md
Normal file
353
.claude/skills/typescript/SKILL.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
name: typescript
|
||||
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
|
||||
---
|
||||
|
||||
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
|
||||
Naming Conventions
|
||||
Type Convention Example
|
||||
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
|
||||
Variables, Parameters, Functions lowerCamelCase userName, processData
|
||||
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
|
||||
Type Parameters Single letter or UpperCamelCase T, ResponseType
|
||||
Naming Principles
|
||||
|
||||
Descriptive names, avoid ambiguous abbreviations
|
||||
Treat acronyms as words: loadHttpUrl not loadHTTPURL
|
||||
No prefixes like opt_ for optional parameters
|
||||
No trailing underscores for private properties
|
||||
Single-letter variables only when scope is <10 lines
|
||||
|
||||
Variable Declarations
|
||||
|
||||
// Always use const by default
|
||||
const users = getUsers();
|
||||
|
||||
// Use let only when reassignment is needed
|
||||
let count = 0;
|
||||
count++;
|
||||
|
||||
// Never use var
|
||||
// var x = 1; // WRONG
|
||||
|
||||
// One variable per declaration
|
||||
const a = 1;
|
||||
const b = 2;
|
||||
// const a = 1, b = 2; // WRONG
|
||||
|
||||
Types and Interfaces
|
||||
Prefer Interfaces Over Type Aliases
|
||||
|
||||
// Good: interface for object shapes
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// Avoid: type alias for object shapes
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Type aliases OK for unions, intersections, mapped types
|
||||
type Status = 'active' | 'inactive';
|
||||
type Combined = TypeA & TypeB;
|
||||
|
||||
Type Inference
|
||||
|
||||
Leverage inference for trivially inferred types:
|
||||
|
||||
// Good: inference is clear
|
||||
const name = 'Alice';
|
||||
const items = [1, 2, 3];
|
||||
|
||||
// Good: explicit for complex expressions
|
||||
const result: ProcessedData = complexTransformation(input);
|
||||
|
||||
Array Types
|
||||
|
||||
// Simple types: use T[]
|
||||
const numbers: number[];
|
||||
const names: readonly string[];
|
||||
|
||||
// Multi-dimensional: use T[][]
|
||||
const matrix: number[][];
|
||||
|
||||
// Complex types: use Array<T>
|
||||
const handlers: Array<(event: Event) => void>;
|
||||
|
||||
Null and Undefined
|
||||
|
||||
// Prefer optional fields over union with undefined
|
||||
interface Config {
|
||||
timeout?: number; // Good
|
||||
// timeout: number | undefined; // Avoid
|
||||
}
|
||||
|
||||
// Type aliases must NOT include |null or |undefined
|
||||
type UserId = string; // Good
|
||||
// type UserId = string | null; // WRONG
|
||||
|
||||
// May use == for null comparison (catches both null and undefined)
|
||||
if (value == null) {
|
||||
// handles both null and undefined
|
||||
}
|
||||
|
||||
Types to Avoid
|
||||
|
||||
// Avoid any - use unknown instead
|
||||
function parse(input: unknown): Data { }
|
||||
|
||||
// Avoid {} - use unknown, Record<string, T>, or object
|
||||
function process(obj: Record<string, unknown>): void { }
|
||||
|
||||
// Use lowercase primitives
|
||||
let name: string; // Good
|
||||
// let name: String; // WRONG
|
||||
|
||||
// Never use wrapper objects
|
||||
// new String('hello') // WRONG
|
||||
|
||||
Classes
|
||||
Structure
|
||||
|
||||
class UserService {
|
||||
// Fields first, initialized where declared
|
||||
private readonly cache = new Map<string, User>();
|
||||
private lastAccess: Date | null = null;
|
||||
|
||||
// Constructor with parameter properties
|
||||
constructor(
|
||||
private readonly api: ApiClient,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
// Methods separated by blank lines
|
||||
async getUser(id: string): Promise<User> {
|
||||
// ...
|
||||
}
|
||||
|
||||
private validateId(id: string): boolean {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
Visibility
|
||||
|
||||
class Example {
|
||||
// private by default, only use public when needed externally
|
||||
private internalState = 0;
|
||||
|
||||
// readonly for properties never reassigned after construction
|
||||
readonly id: string;
|
||||
|
||||
// Never use #private syntax - use TypeScript visibility
|
||||
// #field = 1; // WRONG
|
||||
private field = 1; // Good
|
||||
}
|
||||
|
||||
Avoid Arrow Functions as Properties
|
||||
|
||||
class Handler {
|
||||
// Avoid: arrow function as property
|
||||
// handleClick = () => { ... };
|
||||
|
||||
// Good: instance method
|
||||
handleClick(): void {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// Bind at call site if needed
|
||||
element.addEventListener('click', () => handler.handleClick());
|
||||
|
||||
Static Methods
|
||||
|
||||
Never use this in static methods
|
||||
Call on defining class, not subclasses
|
||||
|
||||
Functions
|
||||
Prefer Function Declarations
|
||||
|
||||
// Good: function declaration for named functions
|
||||
function processData(input: Data): Result {
|
||||
return transform(input);
|
||||
}
|
||||
|
||||
// Arrow functions when type annotation needed
|
||||
const handler: EventHandler = (event) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
Arrow Function Bodies
|
||||
|
||||
// Concise body only when return value is used
|
||||
const double = (x: number) => x * 2;
|
||||
|
||||
// Block body when return should be void
|
||||
const log = (msg: string) => {
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
Parameters
|
||||
|
||||
// Use rest parameters, not arguments
|
||||
function sum(...numbers: number[]): number {
|
||||
return numbers.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
// Destructuring for multiple optional params
|
||||
interface Options {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
}
|
||||
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Never name a parameter 'arguments'
|
||||
|
||||
Imports and Exports
|
||||
Always Use Named Exports
|
||||
|
||||
// Good: named exports
|
||||
export function processData() { }
|
||||
export class UserService { }
|
||||
export interface Config { }
|
||||
|
||||
// Never use default exports
|
||||
// export default class UserService { } // WRONG
|
||||
|
||||
Import Styles
|
||||
|
||||
// Module import for large APIs
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Named imports for frequently used symbols
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
|
||||
// Type-only imports when only used as types
|
||||
import type { User, Config } from './types';
|
||||
|
||||
Module Organization
|
||||
|
||||
Use modules, never namespace Foo { }
|
||||
Never use require() - use ES6 imports
|
||||
Use relative imports within same project
|
||||
Avoid excessive ../../../
|
||||
|
||||
Control Structures
|
||||
Always Use Braces
|
||||
|
||||
// Good
|
||||
if (condition) {
|
||||
doSomething();
|
||||
}
|
||||
|
||||
// Exception: single-line if
|
||||
if (condition) return early;
|
||||
|
||||
Loops
|
||||
|
||||
// Prefer for...of for arrays
|
||||
for (const item of items) {
|
||||
process(item);
|
||||
}
|
||||
|
||||
// Use Object methods with for...of for objects
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Never use unfiltered for...in on arrays
|
||||
|
||||
Equality
|
||||
|
||||
// Always use === and !==
|
||||
if (a === b) { }
|
||||
|
||||
// Exception: == null catches both null and undefined
|
||||
if (value == null) { }
|
||||
|
||||
Switch Statements
|
||||
|
||||
switch (status) {
|
||||
case Status.Active:
|
||||
handleActive();
|
||||
break;
|
||||
case Status.Inactive:
|
||||
handleInactive();
|
||||
break;
|
||||
default:
|
||||
// Always include default, even if empty
|
||||
break;
|
||||
}
|
||||
|
||||
Exception Handling
|
||||
|
||||
// Always throw Error instances
|
||||
throw new Error('Something went wrong');
|
||||
// throw 'error'; // WRONG
|
||||
|
||||
// Catch with unknown type
|
||||
try {
|
||||
riskyOperation();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
logger.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Empty catch needs justification comment
|
||||
try {
|
||||
optional();
|
||||
} catch {
|
||||
// Intentionally ignored: fallback behavior handles this
|
||||
}
|
||||
|
||||
Type Assertions
|
||||
|
||||
// Use 'as' syntax, not angle brackets
|
||||
const input = value as string;
|
||||
// const input = <string>value; // WRONG in TSX, avoid everywhere
|
||||
|
||||
// Double assertion through unknown when needed
|
||||
const config = (rawData as unknown) as Config;
|
||||
|
||||
// Add comment explaining why assertion is safe
|
||||
const element = document.getElementById('app') as HTMLElement;
|
||||
// Safe: element exists in index.html
|
||||
|
||||
Strings
|
||||
|
||||
// Use single quotes for string literals
|
||||
const name = 'Alice';
|
||||
|
||||
// Template literals for interpolation or multiline
|
||||
const message = `Hello, ${name}!`;
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
// Never use backslash line continuations
|
||||
|
||||
Disallowed Features
|
||||
Feature Alternative
|
||||
var const or let
|
||||
Array() constructor [] literal
|
||||
Object() constructor {} literal
|
||||
any type unknown
|
||||
namespace modules
|
||||
require() import
|
||||
Default exports Named exports
|
||||
#private fields private modifier
|
||||
eval() Never use
|
||||
const enum Regular enum
|
||||
debugger Remove before commit
|
||||
with Never use
|
||||
Prototype modification Never modify
|
||||
28
.eslintrc.js
28
.eslintrc.js
@@ -1,28 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true // Allows for the parsing of JSX
|
||||
}
|
||||
},
|
||||
|
||||
plugins: ["@typescript-eslint", "css-modules"],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
},
|
||||
react: {
|
||||
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||
}
|
||||
},
|
||||
|
||||
// Fine tune rules
|
||||
rules: {
|
||||
"@typescript-eslint/no-var-requires": 0
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
export default {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
};
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,19 +1,36 @@
|
||||
FROM node:18.15.0-alpine
|
||||
# Use Node.js 22 as the base image
|
||||
FROM node:22-alpine
|
||||
|
||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /threetwo
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
COPY nodemon.json ./
|
||||
COPY jsdoc.json ./
|
||||
# Copy package.json and yarn.lock to leverage Docker cache
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
||||
RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||
RUN yarn --ignore-engines
|
||||
# Install build dependencies necessary for native modules (for node-sass)
|
||||
RUN apk --no-cache add \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
nasm \
|
||||
git
|
||||
|
||||
# Install node modules
|
||||
RUN yarn install --ignore-engines
|
||||
|
||||
# Explicitly install sass
|
||||
RUN yarn add -D sass
|
||||
|
||||
# Copy the rest of the application files into the container
|
||||
COPY . .
|
||||
|
||||
# Expose the application port (default for Vite)
|
||||
EXPOSE 5173
|
||||
|
||||
ENTRYPOINT [ "npm", "start" ]
|
||||
# Start the application with yarn
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
|
||||
1
__mocks__/fileMock.cjs
Normal file
1
__mocks__/fileMock.cjs
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
16
codegen.yml
Normal file
16
codegen.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
schema: http://localhost:3000/graphql
|
||||
documents: 'src/client/graphql/**/*.graphql'
|
||||
generates:
|
||||
src/client/graphql/generated.ts:
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- typescript-react-query
|
||||
config:
|
||||
fetcher:
|
||||
func: './fetcher#fetcher'
|
||||
isReactHook: false
|
||||
exposeFetcher: true
|
||||
exposeQueryKeys: true
|
||||
addInfiniteQuery: true
|
||||
reactQueryVersion: 5
|
||||
59
eslint.config.js
Normal file
59
eslint.config.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import js from "@eslint/js";
|
||||
import typescript from "@typescript-eslint/eslint-plugin";
|
||||
import typescriptParser from "@typescript-eslint/parser";
|
||||
import react from "eslint-plugin-react";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import cssModules from "eslint-plugin-css-modules";
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescript,
|
||||
react,
|
||||
prettier,
|
||||
"css-modules": cssModules,
|
||||
storybook,
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...typescript.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...prettier.configs.recommended.rules,
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.stories.{js,jsx,ts,tsx}"],
|
||||
rules: {
|
||||
...storybook.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "build/**"],
|
||||
},
|
||||
];
|
||||
28
jest.config.cjs
Normal file
28
jest.config.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.cjs',
|
||||
},
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
||||
'**/?(*.)+(spec|test).+(ts|tsx|js)',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
jsx: 'react',
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.stories.tsx',
|
||||
],
|
||||
};
|
||||
25
jest.setup.cjs
Normal file
25
jest.setup.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
require('@testing-library/jest-dom');
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
13
nodemon.json
13
nodemon.json
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"ignore": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"node_modules",
|
||||
"src/client"
|
||||
],
|
||||
"watch": [
|
||||
"src/server"
|
||||
],
|
||||
"exec": "tsc -p tsconfig.server.json && node server/",
|
||||
"ext": "ts"
|
||||
}
|
||||
224
package.json
224
package.json
@@ -1,127 +1,149 @@
|
||||
{
|
||||
"name": "threetwo",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "ThreeTwo! A good comic book curator.",
|
||||
"main": "server/index.js",
|
||||
"typings": "server/index.js",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "rimraf dist && npm run build && vite",
|
||||
"start": "npm run build && vite",
|
||||
"dev": "rimraf dist && yarn build && vite",
|
||||
"start": "yarn build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"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:watch": "graphql-codegen --config codegen.yml --watch",
|
||||
"knip": "knip"
|
||||
},
|
||||
"author": "Rishi Ghan",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.18",
|
||||
"@floating-ui/react-dom": "^2.1.7",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@tanstack/react-query": "^5.0.5",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||
"axios": "^1.3.4",
|
||||
"axios-cache-interceptor": "^1.0.1",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"airdcpp-apisocket": "^3.0.0-beta.14",
|
||||
"axios": "^1.13.5",
|
||||
"axios-cache-interceptor": "^1.11.4",
|
||||
"axios-rate-limit": "^1.6.2",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.5.1",
|
||||
"express": "^4.19.2",
|
||||
"filename-parser": "^1.0.2",
|
||||
"final-form": "^4.20.2",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"ellipsize": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"filename-parser": "^1.0.4",
|
||||
"final-form": "^5.0.0",
|
||||
"final-form-arrays": "^4.0.0",
|
||||
"focus-trap-react": "^12.0.0",
|
||||
"graphql": "^16.13.1",
|
||||
"history": "^5.3.0",
|
||||
"html-to-text": "^8.1.0",
|
||||
"immer": "^10.0.3",
|
||||
"jsdoc": "^3.6.10",
|
||||
"keen-slider": "^6.8.6",
|
||||
"lodash": "^4.17.21",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"immer": "^11.1.4",
|
||||
"jsdoc": "^4.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"motion": "^12.38.0",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^18.2.0",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"react-final-form-arrays": "^3.1.4",
|
||||
"react-loader-spinner": "^4.0.0",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router": "^6.9.0",
|
||||
"react-router-dom": "^6.9.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select-async-paginate": "^0.7.2",
|
||||
"react-sliding-pane": "^7.1.0",
|
||||
"react-textarea-autosize": "^8.3.4",
|
||||
"reapop": "^4.2.1",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^6.1.0",
|
||||
"qs": "^6.15.0",
|
||||
"react": "^19.2.4",
|
||||
"react-collapsible": "^2.10.0",
|
||||
"react-comic-viewer": "^0.5.1",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-final-form": "^7.0.0",
|
||||
"react-final-form-arrays": "^4.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-loader-spinner": "^8.0.2",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-select-async-paginate": "^0.7.11",
|
||||
"react-sliding-pane": "^7.3.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-toastify": "^11.0.5",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"vite": "^5.0.13",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"zustand": "^4.4.6"
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"websocket": "^1.0.35",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/solar": "^1.1.8",
|
||||
"@iconify/tailwind": "^0.1.4",
|
||||
"@storybook/addon-essentials": "^7.4.1",
|
||||
"@storybook/addon-interactions": "^7.4.1",
|
||||
"@storybook/addon-links": "^7.4.1",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.4.1",
|
||||
"@storybook/react": "^7.4.1",
|
||||
"@storybook/react-vite": "^7.4.1",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.0.5",
|
||||
"@tanstack/react-query-devtools": "^5.1.0",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@types/ellipsize": "^0.1.1",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"body-parser": "^1.19.0",
|
||||
"@graphql-codegen/cli": "^6.1.2",
|
||||
"@graphql-codegen/typescript": "^5.0.8",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||
"@graphql-codegen/typescript-react-query": "^6.1.2",
|
||||
"@iconify-json/solar": "^1.2.5",
|
||||
"@iconify/json": "^2.2.443",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-interactions": "^8.6.17",
|
||||
"@storybook/addon-links": "^8.6.17",
|
||||
"@storybook/addon-onboarding": "^8.6.17",
|
||||
"@storybook/blocks": "^8.6.17",
|
||||
"@storybook/react": "^8.6.17",
|
||||
"@storybook/react-vite": "^8.6.17",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/ellipsize": "^0.1.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"docdash": "^2.0.2",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsdoc": "^46.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-storybook": "^0.6.13",
|
||||
"express": "^4.19.2",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-storybook": "^0.11.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"install": "^0.13.0",
|
||||
"jest": "^29.6.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-import": "^15.1.0",
|
||||
"prettier": "^2.2.1",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.69.5",
|
||||
"storybook": "^7.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"sass": "^1.97.3",
|
||||
"storybook": "^8.6.17",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^6.0.2",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"jackspeak": "2.1.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
43
src/app.css
Normal file
43
src/app.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
/* Custom Project Fonts */
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Heavy";
|
||||
src: url("/fonts/PPObjectSans-Heavy.otf") format("opentype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Slanted";
|
||||
src: url("/fonts/PPObjectSans-Slanted.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans HeavySlanted";
|
||||
src: url("/fonts/PPObjectSans-HeavySlanted.otf") format("opentype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -7,11 +7,12 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
|
||||
|
||||
It is based on React 18, and uses:
|
||||
|
||||
1. _Redux_ for state management
|
||||
1. _zustand_ for state management
|
||||
2. _socket.io_ for transferring data in real-time
|
||||
3. _React Router_ for routing
|
||||
4. React DnD for drag-and-drop
|
||||
5. @tanstack/react-table for all tables
|
||||
6. @tanstack/react-query for API calls
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export const toggleAirDCPPSocketConnectionStatus =
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Can't set AirDC++ socket status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,10 +73,9 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Could not complete request.");
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
dispatch({
|
||||
type: CV_API_GENERIC_FAILURE,
|
||||
error,
|
||||
@@ -99,7 +98,6 @@ export const getIssuesForSeries =
|
||||
comicObjectID,
|
||||
},
|
||||
});
|
||||
console.log(issues);
|
||||
dispatch({
|
||||
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
issues: issues.data.results,
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
LS_SET_QUEUE_STATUS,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { success } from "react-notification-system-redux";
|
||||
|
||||
import { isNil } from "lodash";
|
||||
|
||||
@@ -151,7 +150,7 @@ export const getComicBooks = (options) => async (dispatch) => {
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.log("Unrecognized comic status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -219,12 +218,11 @@ export const fetchVolumeGroups = () => async (dispatch) => {
|
||||
data: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
export const fetchComicVineMatches =
|
||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||
console.log(issueSearchQuery);
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
@@ -273,7 +271,7 @@ export const fetchComicVineMatches =
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
|
||||
dispatch({
|
||||
|
||||
@@ -7,8 +7,6 @@ export const fetchMetronResource = async (options) => {
|
||||
`${METRON_SERVICE_URI}/fetchResource`,
|
||||
options,
|
||||
);
|
||||
console.log(metronResourceResults);
|
||||
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||
const results = metronResourceResults.data.results.map((result) => {
|
||||
return {
|
||||
label: result.name || result.__str__,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import "../assets/scss/App.scss";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../../app.css";
|
||||
import { useStore } from "../store";
|
||||
|
||||
export const App = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar2 />
|
||||
<Outlet />
|
||||
<ToastContainer stacked hideProgressBar />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
|
||||
import React, {
|
||||
useCallback,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
@@ -10,6 +15,8 @@ import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
@@ -21,150 +28,117 @@ interface IAcquisitionPanelProps {
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPDownloadTick,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
||||
})),
|
||||
);
|
||||
const socketRef = useRef<Socket>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
interface SearchData {
|
||||
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
||||
hub_urls: string[] | undefined | null;
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
||||
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
});
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
||||
// Pre-populate the search input with the search string, so that
|
||||
// All the user has to do is hit "Search AirDC++"
|
||||
useEffect(() => {
|
||||
// AirDC++ search query
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
// --- Handlers ---
|
||||
const handleResultAdded = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) =>
|
||||
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||
);
|
||||
};
|
||||
|
||||
const handleResultUpdated = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === result.id);
|
||||
if (idx === -1) return prev;
|
||||
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = result;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInitiated = (data: any) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
};
|
||||
|
||||
const handleSearchesSent = (data: any) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
};
|
||||
|
||||
// --- Subscribe once ---
|
||||
socket.on("searchResultAdded", handleResultAdded);
|
||||
socket.on("searchResultUpdated", handleResultUpdated);
|
||||
socket.on("searchInitiated", handleSearchInitiated);
|
||||
socket.on("searchesSent", handleSearchesSent);
|
||||
|
||||
return () => {
|
||||
socket.off("searchResultAdded", handleResultAdded);
|
||||
socket.off("searchResultUpdated", handleResultUpdated);
|
||||
socket.off("searchInitiated", handleSearchInitiated);
|
||||
socket.off("searchesSent", handleSearchesSent);
|
||||
// if you want to fully close the socket:
|
||||
// useStore.getState().disconnectSocket("/manual");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: settings?.data.directConnect?.client?.host,
|
||||
},
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs, (item) => item.value),
|
||||
hub_urls: map(hubs?.data, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}, []);
|
||||
}, [hubs, sanitizedIssueName]);
|
||||
|
||||
/**
|
||||
* Method to perform a search via an AirDC++ websocket
|
||||
* @param {SearchData} data - a SearchData query
|
||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||
*/
|
||||
const search = async (data: SearchData, ADCPPSocket: any) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket();
|
||||
}
|
||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||
setAirDCPPSearchStatus(true);
|
||||
|
||||
// We want to get notified about every new result in order to make the user experience better
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_added",
|
||||
async (groupedResult) => {
|
||||
// ...add the received result in the UI
|
||||
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
|
||||
setAirDCPPSearchResults((state) => [...state, groupedResult]);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_updated",
|
||||
async (groupedResult) => {
|
||||
// ...update properties of the existing result in the UI
|
||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||
(bundle) => bundle.result.id === groupedResult.result.id,
|
||||
);
|
||||
const updatedState = [...airDCPPSearchResults];
|
||||
if (
|
||||
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
|
||||
) {
|
||||
updatedState[bundleToUpdateIndex] = groupedResult;
|
||||
}
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
|
||||
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_hub_searches_sent",
|
||||
async (searchInfo) => {
|
||||
await sleep(5000);
|
||||
|
||||
// Check the number of received results (in real use cases we should know that even without calling the API)
|
||||
const currentInstance = await ADCPPSocket.get(
|
||||
`search/${instance.id}`,
|
||||
);
|
||||
setAirDCPPSearchInstance(currentInstance);
|
||||
setAirDCPPSearchInfo(searchInfo);
|
||||
if (currentInstance.result_count === 0) {
|
||||
// ...nothing was received, show an informative message to the user
|
||||
console.log("No more search results.");
|
||||
}
|
||||
|
||||
// The search can now be considered to be "complete"
|
||||
// If there's an "in progress" indicator in the UI, that could also be disabled here
|
||||
setAirDCPPSearchInstance(instance);
|
||||
setAirDCPPSearchStatus(false);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
// Finally, perform the actual search
|
||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
const search = async (searchData: any) => {
|
||||
setAirDCPPSearchResults([]);
|
||||
socketRef.current?.emit("call", "socket.search", {
|
||||
query: searchData,
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to download a bundle associated with a search result from AirDC++
|
||||
* @param {Number} searchInstanceId - description
|
||||
* @param {String} resultId - description
|
||||
* @param {String} comicObjectId - description
|
||||
* @param {String} name - description
|
||||
* @param {Number} size - description
|
||||
* @param {any} type - description
|
||||
* @param {any} ADCPPSocket - description
|
||||
* @returns {void} - description
|
||||
*/
|
||||
const download = async (
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
@@ -172,68 +146,43 @@ export const AcquisitionPanel = (
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
ADCPPSocket: any,
|
||||
): void => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
let bundleDBImportResult = {};
|
||||
const downloadResult = await ADCPPSocket.post(
|
||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||
);
|
||||
|
||||
if (!isNil(downloadResult)) {
|
||||
bundleDBImportResult = await axios({
|
||||
method: "POST",
|
||||
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
bundleId: downloadResult.bundle_info.id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
},
|
||||
});
|
||||
console.log(bundleDBImportResult?.data);
|
||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||
|
||||
// dispatch({
|
||||
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
// downloadResult,
|
||||
// bundleDBImportResult,
|
||||
// });
|
||||
//
|
||||
// dispatch({
|
||||
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
// comicBookDetail: bundleDBImportResult.data,
|
||||
// IMS_inProgress: false,
|
||||
// });
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
config: any,
|
||||
): Promise<void> => {
|
||||
socketRef.current?.emit(
|
||||
"call",
|
||||
"socket.download",
|
||||
{
|
||||
searchInstanceId,
|
||||
resultId,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
config,
|
||||
},
|
||||
(data: any) => {
|
||||
// Download initiated
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getDCPPSearchResults = async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs, (hub) => hub.hub_url),
|
||||
hub_urls: [hubs?.data[0].hub_url],
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
search(manualQuery, airDCPPSocketInstance);
|
||||
search(manualQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-5">
|
||||
{!isEmpty(airDCPPSocketInstance) ? (
|
||||
<div className="mt-5 mb-3">
|
||||
{!isEmpty(hubs?.data) ? (
|
||||
<Form
|
||||
onSubmit={getDCPPSearchResults}
|
||||
initialValues={{
|
||||
@@ -278,16 +227,24 @@ export const AcquisitionPanel = (
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="">
|
||||
<article className="">
|
||||
<div className="">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Connection</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm 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"
|
||||
>
|
||||
No AirDC++ hub configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Hubs</code>.
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
{/* configured hub */}
|
||||
{!isEmpty(hubs?.data) && (
|
||||
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{hubs && hubs?.data[0].hub_url}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(airDCPPSearchInstance) &&
|
||||
@@ -298,7 +255,7 @@ export const AcquisitionPanel = (
|
||||
<dl>
|
||||
<dt>
|
||||
<div className="mb-1">
|
||||
{hubs.map((value, idx) => (
|
||||
{hubs?.data.map((value, idx: string) => (
|
||||
<span className="tag is-warning" key={idx}>
|
||||
{value.identity.name}
|
||||
</span>
|
||||
@@ -337,134 +294,118 @@ export const AcquisitionPanel = (
|
||||
)}
|
||||
|
||||
{/* AirDC++ results */}
|
||||
<div className="columns">
|
||||
<div className="">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
|
||||
<div className="overflow-x-auto max-w-full mt-6">
|
||||
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Slots
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
||||
return (
|
||||
<tbody>
|
||||
{map(
|
||||
airDCPPSearchResults,
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(result.dupe)
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "w-fit text-sm"
|
||||
!isNil(dupe)
|
||||
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
|
||||
: "border-b border-gray-200 dark:border-slate-700 text-sm"
|
||||
}
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
||||
{/* NAME */}
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||
<p className="mb-2">
|
||||
{result.type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}
|
||||
{ellipsize(result.name, 70)}
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
{type.id === "directory" && (
|
||||
<i className="fas fa-folder mr-1"></i>
|
||||
)}
|
||||
{ellipsize(name, 45)}
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-row gap-2">
|
||||
{!isNil(result.dupe) ? (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
Dupe
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Nicks */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--user-rounded-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{result.users.user.nicks}
|
||||
<div className="inline-flex flex-wrap gap-1">
|
||||
{!isNil(dupe) && (
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||
Dupe
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||
{users.user.nicks}
|
||||
</span>
|
||||
{/* Flags */}
|
||||
{result.users.user.flags.map((flag, idx) => (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{flag}
|
||||
</span>
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
|
||||
>
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||
{flag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
{/* Extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{result.type.str}
|
||||
</span>
|
||||
{/* TYPE */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||
{type.str}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{/* Slots */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{result.slots.total} slots; {result.slots.free} free
|
||||
</span>
|
||||
{/* SLOTS */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||
{slots.total} slots; {slots.free} free
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
|
||||
{/* ACTIONS */}
|
||||
<td className="px-2 py-3">
|
||||
<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"
|
||||
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
result.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
result.name,
|
||||
result.size,
|
||||
result.type,
|
||||
airDCPPSocketInstance,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-xs">Download</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
|
||||
</span>
|
||||
Download
|
||||
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -3,44 +3,55 @@ import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import ellipsize from "ellipsize";
|
||||
import { map } from "lodash";
|
||||
|
||||
import { DownloadProgressTick } from "./DownloadProgressTick";
|
||||
export const AirDCPPBundles = (props) => {
|
||||
return (
|
||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<div className="overflow-x-auto w-fit mt-6">
|
||||
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Filename
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Download Time
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Download Status
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Bundle ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{map(props.data, (bundle) => (
|
||||
<tr key={bundle.id} className="text-sm">
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||
<span className="text-xs">{ellipsize(bundle.target, 88)}</span>
|
||||
<tbody>
|
||||
{map(props.data, (bundle, index) => (
|
||||
<tr
|
||||
key={bundle.id}
|
||||
className={
|
||||
Number(index) !== props.data.length - 1
|
||||
? "border-b border-gray-200 dark:border-slate-700"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<h5 className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{ellipsize(bundle.name, 58)}
|
||||
</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
{ellipsize(bundle.target, 88)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<td className="px-3 py-2 align-top">
|
||||
{prettyBytes(bundle.size)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{dayjs
|
||||
.unix(bundle.time_finished)
|
||||
.format("h:mm on ddd, D MMM, YYYY")}
|
||||
<td className="px-3 py-2 align-top">
|
||||
<DownloadProgressTick bundleId={bundle.id} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">{bundle.id}</span>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium">
|
||||
{bundle.id}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||
import Creatable from "react-select/creatable";
|
||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||
|
||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
interface AsyncSelectPaginateProps {
|
||||
metronResource: string;
|
||||
placeholder?: string;
|
||||
value?: object;
|
||||
onChange?(...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||
const [value, setValue] = useState(null);
|
||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||
|
||||
@@ -38,11 +44,4 @@ export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
AsyncSelectPaginate.propTypes = {
|
||||
metronResource: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default AsyncSelectPaginate;
|
||||
|
||||
@@ -1,50 +1,70 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import React, { useState, ReactElement, useCallback, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
|
||||
import { RawFileDetails } from "./RawFileDetails";
|
||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||
|
||||
import TabControls from "./TabControls";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
import { Menu } from "./ActionMenu/Menu";
|
||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||
import AcquisitionPanel from "./AcquisitionPanel";
|
||||
import TorrentSearchPanel from "./TorrentSearchPanel";
|
||||
import DownloadsPanel from "./DownloadsPanel";
|
||||
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||
|
||||
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||
import { components } from "react-select";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
|
||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import Loader from "react-loader-spinner";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import Modal from "react-modal";
|
||||
import ComicViewer from "react-comic-viewer";
|
||||
|
||||
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import axios from "axios";
|
||||
import { styled } from "styled-components";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { refineQuery } from "filename-parser";
|
||||
import type { RawFileDetails as RawFileDetailsType, InferredMetadata } from "../../graphql/generated";
|
||||
|
||||
// Extracted modules
|
||||
import { useComicVineMatching } from "./useComicVineMatching";
|
||||
import { createTabConfig } from "./tabConfig";
|
||||
import { actionOptions, customStyles, ActionOption } from "./actionMenuConfig";
|
||||
import { CVMatchesPanel, EditMetadataPanelWrapper } from "./SlidingPanelContent";
|
||||
|
||||
// Styled component - moved outside to prevent recreation
|
||||
const StyledSlidingPanel = styled(SlidingPane)`
|
||||
background: #ccc;
|
||||
`;
|
||||
|
||||
interface ComicVineMetadata {
|
||||
name?: string;
|
||||
volumeInformation?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Acquisition {
|
||||
directconnect?: {
|
||||
downloads?: unknown[];
|
||||
};
|
||||
torrent?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ComicDetailProps {
|
||||
data: {
|
||||
_id: string;
|
||||
rawFileDetails?: RawFileDetailsType;
|
||||
inferredMetadata: InferredMetadata;
|
||||
sourcedMetadata: {
|
||||
comicvine?: ComicVineMetadata;
|
||||
locg?: Record<string, unknown>;
|
||||
comicInfo?: Record<string, unknown>;
|
||||
};
|
||||
acquisition?: Acquisition;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
userSettings?: Record<string, unknown>;
|
||||
queryClient?: unknown;
|
||||
comicObjectId?: string;
|
||||
}
|
||||
|
||||
type ComicDetailProps = {};
|
||||
/**
|
||||
* Component for displaying the metadata for a comic in greater detail.
|
||||
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
||||
* for metadata, archive operations, and acquisition.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* return (
|
||||
* <ComicDetail/>
|
||||
* )
|
||||
* @param data.queryClient - react-query client passed through to the CV match
|
||||
* panel so it can invalidate queries after a match is applied.
|
||||
* @param data.comicObjectId - optional override for the comic ID; used when the
|
||||
* component is rendered outside a route that provides the ID via `useParams`.
|
||||
*/
|
||||
|
||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const {
|
||||
data: {
|
||||
@@ -54,135 +74,22 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||
acquisition,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
userSettings,
|
||||
queryClient,
|
||||
comicObjectId: comicObjectIdProp,
|
||||
} = data;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [modalIsOpen, setIsOpen] = useState(false);
|
||||
const [comicVineMatches, setComicVineMatches] = useState([]);
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
const openModal = useCallback((filePath) => {
|
||||
setIsOpen(true);
|
||||
// dispatch(
|
||||
// extractComicArchive(filePath, {
|
||||
// type: "full",
|
||||
// purpose: "reading",
|
||||
// imageResizeOptions: {
|
||||
// baseWidth: 1024,
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
}, []);
|
||||
|
||||
// overridden <SlidingPanel> with some styles
|
||||
const StyledSlidingPanel = styled(SlidingPane)`
|
||||
background: #ccc;
|
||||
`;
|
||||
const afterOpenModal = useCallback((things) => {
|
||||
// references are now sync'd and can be accessed.
|
||||
// subtitle.style.color = "#f00";
|
||||
console.log("kolaveri", things);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
CVMatches: {
|
||||
content: (props) => (
|
||||
<>
|
||||
<div>
|
||||
<ComicVineSearchForm data={rawFileDetails} />
|
||||
</div>
|
||||
|
||||
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||
<p className="">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<>
|
||||
<span className="">{inferredMetadata.issue.name} </span>
|
||||
<span className=""> # {inferredMetadata.issue.number} </span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
editComicBookMetadata: {
|
||||
content: () => <EditMetadataPanel data={rawFileDetails} />,
|
||||
},
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
const fetchComicVineMatches = async (
|
||||
searchPayload,
|
||||
issueSearchQuery,
|
||||
seriesSearchQuery,
|
||||
) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
// hack
|
||||
query: issueSearchQuery.inferredIssueDetails.name
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim(),
|
||||
limit: "100",
|
||||
page: 1,
|
||||
resources: "volume",
|
||||
scorerConfiguration: {
|
||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||
},
|
||||
rawFileDetails: searchPayload,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
// return sortBy(matches, (match) => -match.score);
|
||||
},
|
||||
});
|
||||
let matches: any = [];
|
||||
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||
matches = response.data.results;
|
||||
} else {
|
||||
matches = response.data.map((match) => match);
|
||||
}
|
||||
const scoredMatches = matches.sort((a, b) => b.score - a.score);
|
||||
setComicVineMatches(scoredMatches);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
||||
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
|
||||
if (!isUndefined(rawFileDetails)) {
|
||||
issueSearchQuery = refineQuery(rawFileDetails.name);
|
||||
} else if (!isEmpty(comicvine)) {
|
||||
issueSearchQuery = refineQuery(comicvine.name);
|
||||
}
|
||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
setVisible(true);
|
||||
};
|
||||
@@ -192,47 +99,17 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// Actions menu options and handler
|
||||
const CVMatchLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Match on ComicVine</div>
|
||||
</span>
|
||||
);
|
||||
const editLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Edit Metadata</div>
|
||||
</span>
|
||||
);
|
||||
const deleteLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Delete Comic</div>
|
||||
</span>
|
||||
);
|
||||
const Placeholder = (props) => {
|
||||
return <components.Placeholder {...props} />;
|
||||
};
|
||||
const actionOptions = [
|
||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||
{ value: "edit-metdata", label: editLabel },
|
||||
{ value: "delete-comic", label: deleteLabel },
|
||||
];
|
||||
|
||||
// Hide "match on Comic Vine" when there are no raw file details — matching
|
||||
// requires file metadata to seed the search query.
|
||||
const Placeholder = components.Placeholder;
|
||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||
if (isUndefined(rawFileDetails)) {
|
||||
return item.value !== "match-on-comic-vine";
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const handleActionSelection = (action) => {
|
||||
|
||||
const handleActionSelection = (action: ActionOption) => {
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
@@ -241,40 +118,19 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
openEditMetadataPanel();
|
||||
break;
|
||||
default:
|
||||
console.log("No valid action selected.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
const customStyles = {
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
}),
|
||||
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
paddingTop: "0.4rem",
|
||||
}),
|
||||
control: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
color: "black",
|
||||
border: "1px solid rgb(156, 163, 175)",
|
||||
}),
|
||||
};
|
||||
|
||||
// check for the availability of CV metadata
|
||||
// Check for metadata availability
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
||||
|
||||
const hasAnyMetadata =
|
||||
isComicBookMetadataAvailable ||
|
||||
!isEmpty(comicInfo) ||
|
||||
!isNil(locg);
|
||||
|
||||
// check for the availability of rawFileDetails
|
||||
const areRawFileDetailsAvailable =
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||
|
||||
@@ -284,110 +140,58 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
locg,
|
||||
});
|
||||
|
||||
// query for airdc++
|
||||
const airDCPPQuery = {
|
||||
issue: {
|
||||
name: issueName,
|
||||
},
|
||||
// Query for airdc++
|
||||
const airDCPPQuery = useMemo(() => ({
|
||||
issue: { name: issueName },
|
||||
}), [issueName]);
|
||||
|
||||
// Create tab configuration
|
||||
const openReconcilePanel = useCallback(() => {
|
||||
setSlidingPanelContentId("metadataReconciliation");
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const tabGroup = useMemo(() => createTabConfig({
|
||||
data: data.data,
|
||||
hasAnyMetadata,
|
||||
areRawFileDetailsAvailable,
|
||||
airDCPPQuery,
|
||||
comicObjectId: _id,
|
||||
userSettings,
|
||||
issueName,
|
||||
acquisition,
|
||||
onReconcileMetadata: openReconcilePanel,
|
||||
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
|
||||
|
||||
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
|
||||
|
||||
// Sliding panel content mapping
|
||||
const renderSlidingPanelContent = () => {
|
||||
switch (slidingPanelContentId) {
|
||||
case "CVMatches":
|
||||
return (
|
||||
<CVMatchesPanel
|
||||
rawFileDetails={rawFileDetails}
|
||||
inferredMetadata={inferredMetadata}
|
||||
comicVineMatches={comicVineMatches}
|
||||
// Prefer the route param; fall back to the data ID when rendered outside a route.
|
||||
comicObjectId={comicObjectId || _id}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={() => {
|
||||
setVisible(false);
|
||||
setActiveTab(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "editComicBookMetadata":
|
||||
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tab content and header details
|
||||
const tabGroup = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Volume Information",
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||
),
|
||||
content: isComicBookMetadataAvailable ? (
|
||||
<VolumeInformation data={data.data} key={1} />
|
||||
) : null,
|
||||
shouldShow: isComicBookMetadataAvailable,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ComicInfo.xml",
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
content: (
|
||||
<div key={2}>
|
||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||
</div>
|
||||
),
|
||||
shouldShow: !isEmpty(comicInfo),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "Archive Operations",
|
||||
content: <ArchiveOperations data={data.data} key={3} />,
|
||||
shouldShow: areRawFileDetailsAvailable,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "DC++ Search",
|
||||
content: (
|
||||
<AcquisitionPanel
|
||||
query={airDCPPQuery}
|
||||
comicObjectId={_id}
|
||||
comicObject={data.data}
|
||||
userSettings={userSettings}
|
||||
key={4}
|
||||
/>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: (
|
||||
<span className="inline-flex flex-row">
|
||||
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
</span>
|
||||
),
|
||||
name: "Torrent Search",
|
||||
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Downloads",
|
||||
icon: (
|
||||
<>
|
||||
{acquisition?.directconnect?.downloads?.length +
|
||||
acquisition?.torrent.length}
|
||||
</>
|
||||
),
|
||||
content:
|
||||
!isNil(data.data) && !isEmpty(data.data) ? (
|
||||
<DownloadsPanel key={5} />
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
];
|
||||
// filtered Tabs
|
||||
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||
|
||||
// Determine which cover image to use:
|
||||
// 1. from the locally imported or
|
||||
// 2. from the CV-scraped version
|
||||
|
||||
return (
|
||||
<section className="container mx-auto">
|
||||
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="section">
|
||||
{!isNil(data) && !isEmpty(data) && (
|
||||
<>
|
||||
@@ -401,14 +205,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
!isEmpty(rawFileDetails?.cover) && (
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
rawFileDetails: rawFileDetails,
|
||||
inferredMetadata: inferredMetadata,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
createdAt,
|
||||
}}
|
||||
>
|
||||
{/* action dropdown */}
|
||||
@@ -425,25 +228,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
</RawFileDetails>
|
||||
|
||||
{/* <Modal
|
||||
style={{ content: { marginTop: "2rem" } }}
|
||||
isOpen={modalIsOpen}
|
||||
onAfterOpen={afterOpenModal}
|
||||
onRequestClose={closeModal}
|
||||
contentLabel="Example Modal"
|
||||
>
|
||||
<button onClick={closeModal}>close</button>
|
||||
{extractedComicBook && (
|
||||
<ComicViewer
|
||||
pages={extractedComicBook}
|
||||
direction="ltr"
|
||||
className={{
|
||||
closeButton: "border: 1px solid red;",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -451,7 +235,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<StyledSlidingPanel
|
||||
@@ -460,8 +246,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
title={"Comic Vine Search Matches"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
{renderSlidingPanelContent()}
|
||||
</StyledSlidingPanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
|
||||
|
||||
export const ComicDetailContainer = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: comicBookDetailData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["comicBookMetadata"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicObjectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
{
|
||||
isError && <>Error</>;
|
||||
}
|
||||
{
|
||||
isLoading && <>Loading...</>;
|
||||
}
|
||||
return (
|
||||
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
|
||||
} = useGetComicByIdQuery(
|
||||
{ id: comicObjectId! },
|
||||
{ enabled: !!comicObjectId }
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading comic details</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const adaptedData = comicBookDetailData?.comic
|
||||
? adaptGraphQLComicToLegacy(comicBookDetailData.comic)
|
||||
: null;
|
||||
|
||||
return adaptedData ? (
|
||||
<ComicDetail
|
||||
data={adaptedData}
|
||||
queryClient={queryClient}
|
||||
comicObjectId={comicObjectId}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import { convert } from "html-to-text";
|
||||
|
||||
export const ComicVineDetails = (props): ReactElement => {
|
||||
interface ComicVineDetailsProps {
|
||||
updatedAt?: string;
|
||||
data?: {
|
||||
name?: string;
|
||||
number?: string;
|
||||
resource_type?: string;
|
||||
id?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||
const { data, updatedAt } = props;
|
||||
return (
|
||||
<div className="text-slate-500 dark:text-gray-400">
|
||||
@@ -107,13 +116,3 @@ export const ComicVineDetails = (props): ReactElement => {
|
||||
};
|
||||
|
||||
export default ComicVineDetails;
|
||||
|
||||
ComicVineDetails.propTypes = {
|
||||
updatedAt: PropTypes.string,
|
||||
data: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.string,
|
||||
resource_type: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||
interface ComicVineMatchPanelProps {
|
||||
props: {
|
||||
comicObjectId: string;
|
||||
comicVineMatches: any[];
|
||||
queryClient?: any;
|
||||
onMatchApplied?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** Displays ComicVine search results or a status message while searching. */
|
||||
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
|
||||
const { comicvine } = useStore(
|
||||
useShallow((state) => ({
|
||||
comicvine: state.comicvine,
|
||||
@@ -19,6 +28,8 @@ export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
<MatchResult
|
||||
matchData={comicVineMatches}
|
||||
comicObjectId={comicObjectId}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={onMatchApplied}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,32 +1,127 @@
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { useStore } from "../../store";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
/**
|
||||
* @typedef {Object} DownloadProgressTickProps
|
||||
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
|
||||
*/
|
||||
interface DownloadProgressTickProps {
|
||||
bundleId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the download tick data received over the socket.
|
||||
*
|
||||
* @typedef DownloadTickData
|
||||
* @property {number} id - Unique download ID
|
||||
* @property {string} name - File name (e.g. "movie.mkv")
|
||||
* @property {number} downloaded_bytes - Bytes downloaded so far
|
||||
* @property {number} size - Total size in bytes
|
||||
* @property {number} speed - Current download speed (bytes/sec)
|
||||
* @property {number} seconds_left - Estimated seconds remaining
|
||||
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
|
||||
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
|
||||
* @property {{ online: number; total: number; str: string }} sources
|
||||
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
|
||||
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
|
||||
*/
|
||||
interface DownloadTickData {
|
||||
id: number;
|
||||
name: string;
|
||||
downloaded_bytes: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
seconds_left: number;
|
||||
status: {
|
||||
id: string;
|
||||
str: string;
|
||||
completed: boolean;
|
||||
downloaded: boolean;
|
||||
failed: boolean;
|
||||
hook_error: any;
|
||||
};
|
||||
sources: {
|
||||
online: number;
|
||||
total: number;
|
||||
str: string;
|
||||
};
|
||||
target: string;
|
||||
}
|
||||
|
||||
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||
bundleId,
|
||||
}): ReactElement | null => {
|
||||
const socketRef = useRef<Socket>();
|
||||
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.emit("call", "socket.listenFileProgress", {
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler for each "downloadTick" event.
|
||||
* Only update state if event.id matches bundleId.
|
||||
*
|
||||
* @param {DownloadTickData} data - Payload from the server
|
||||
*/
|
||||
const onDownloadTick = (data: DownloadTickData) => {
|
||||
// Compare numeric data.id to string bundleId
|
||||
if (data.id === parseInt(bundleId, 10)) {
|
||||
setTick(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("downloadTick", onDownloadTick);
|
||||
return () => {
|
||||
socket.off("downloadTick", onDownloadTick);
|
||||
};
|
||||
}, [socketRef, bundleId]);
|
||||
|
||||
if (!tick) {
|
||||
return <>Nothing detected.</>;
|
||||
}
|
||||
|
||||
// Compute human-readable values and percentages
|
||||
const downloaded = prettyBytes(tick.downloaded_bytes);
|
||||
const total = prettyBytes(tick.size);
|
||||
const percent = tick.size > 0
|
||||
? Math.round((tick.downloaded_bytes / tick.size) * 100)
|
||||
: 0;
|
||||
const speed = prettyBytes(tick.speed) + "/s";
|
||||
const minutesLeft = Math.round(tick.seconds_left / 60);
|
||||
|
||||
export const DownloadProgressTick = (props): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="is-size-5">{props.data.name}</h4>
|
||||
<div>
|
||||
<span className="is-size-4 has-text-weight-semibold">
|
||||
{prettyBytes(props.data.downloaded_bytes)} of{" "}
|
||||
{prettyBytes(props.data.size)}{" "}
|
||||
</span>
|
||||
<progress
|
||||
className="progress is-small is-success"
|
||||
value={props.data.downloaded_bytes}
|
||||
max={props.data.size}
|
||||
>
|
||||
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
|
||||
100}
|
||||
%
|
||||
</progress>
|
||||
</div>
|
||||
<div className="is-size-6 mt-1 mb-2">
|
||||
<p>{prettyBytes(props.data.speed)} per second.</p>
|
||||
Time left:
|
||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
||||
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||
{/* Downloaded vs Total */}
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||
</div>
|
||||
|
||||
<div>{props.data.target}</div>
|
||||
{/* Progress bar */}
|
||||
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-green-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
|
||||
|
||||
{/* Speed and Time Left */}
|
||||
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
|
||||
<span>Speed: {speed}</span>
|
||||
<span>Time left: {minutesLeft} min</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
import { isEmpty, map } from "lodash";
|
||||
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||
import { TorrentDownloads } from "./TorrentDownloads";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -14,134 +13,142 @@ import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface IDownloadsPanelProps {
|
||||
key: number;
|
||||
export interface TorrentDetails {
|
||||
infoHash: string;
|
||||
progress: number;
|
||||
downloadSpeed?: number;
|
||||
uploadSpeed?: number;
|
||||
}
|
||||
|
||||
export const DownloadsPanel = (
|
||||
props: IDownloadsPanelProps,
|
||||
): ReactElement | null => {
|
||||
/**
|
||||
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||
* - DC++ (AirDCPP) bundles
|
||||
* - Torrent downloads
|
||||
* It also listens for real-time torrent updates via a WebSocket.
|
||||
*
|
||||
* @component
|
||||
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
|
||||
*/
|
||||
export const DownloadsPanel = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const [bundles, setBundles] = useState([]);
|
||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||
const [torrentDetails, setTorrentDetails] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("torrents");
|
||||
const { airDCPPSocketInstance, socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||
"directconnect",
|
||||
);
|
||||
|
||||
// React to torrent progress data sent over websockets
|
||||
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
|
||||
const torrents = data.torrents
|
||||
.flatMap(({ _id, details }) => {
|
||||
if (_id === comicObjectId) {
|
||||
return details;
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Registers socket listeners on mount and cleans up on unmount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socketIOInstance) return;
|
||||
|
||||
/**
|
||||
* Handler for incoming torrent data events.
|
||||
* Merges new entries or updates existing ones by infoHash.
|
||||
*
|
||||
* @param {TorrentDetails} data - Payload from the socket event.
|
||||
*/
|
||||
const handleTorrentData = (data: TorrentDetails) => {
|
||||
setTorrentDetails((prev) => {
|
||||
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
|
||||
if (idx === -1) {
|
||||
return [...prev, data];
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== undefined);
|
||||
setTorrentDetails(torrents);
|
||||
});
|
||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
||||
const { data: comicObject, isSuccess } = useQuery({
|
||||
queryKey: ["bundles"],
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
|
||||
|
||||
return () => {
|
||||
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
|
||||
};
|
||||
}, [socketIOInstance]);
|
||||
|
||||
// ————— DC++ Bundles (via REST) —————
|
||||
const { data: bundles } = useQuery({
|
||||
queryKey: ["bundles", comicObjectId],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
id: `${comicObjectId}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const getBundles = async (comicObject) => {
|
||||
if (comicObject?.data.acquisition.directconnect) {
|
||||
const filteredBundles =
|
||||
comicObject.data.acquisition.directconnect.downloads.map(
|
||||
async ({ bundleId }) => {
|
||||
return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
|
||||
comicObjectId,
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
);
|
||||
return await Promise.all(filteredBundles);
|
||||
}
|
||||
};
|
||||
|
||||
// Call the scheduled job for fetching torrent data
|
||||
// triggered by the active tab been set to "torrents"
|
||||
const { data: torrentData } = useQuery({
|
||||
queryFn: () =>
|
||||
axios({
|
||||
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||
method: "GET",
|
||||
params: {
|
||||
trigger: activeTab,
|
||||
},
|
||||
}),
|
||||
queryKey: [activeTab],
|
||||
});
|
||||
|
||||
// ————— Torrent Jobs (via REST) —————
|
||||
const { data: rawJobs = [] } = useQuery<any[]>({
|
||||
queryKey: ["torrents", comicObjectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get(
|
||||
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||
{ params: { trigger: activeTab } },
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
initialData: [],
|
||||
enabled: activeTab === "torrents",
|
||||
});
|
||||
|
||||
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
|
||||
useEffect(() => {
|
||||
getBundles(comicObject).then((result) => {
|
||||
setBundles(result);
|
||||
});
|
||||
}, [comicObject]);
|
||||
if (activeTab !== "torrents") return;
|
||||
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="columns is-multiline">
|
||||
{!isEmpty(airDCPPSocketInstance) &&
|
||||
!isEmpty(bundles) &&
|
||||
activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
|
||||
<>
|
||||
<div className="mt-5 mb-3">
|
||||
<nav className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
DC++
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "torrents"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Torrents
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="Download Type" className="sr-only">
|
||||
Download Type
|
||||
</label>
|
||||
|
||||
<select id="Tab" className="w-full rounded-md border-gray-200">
|
||||
<option>DC++ Downloads</option>
|
||||
<option>Torrents</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
>
|
||||
DC++ Downloads
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "torrents"
|
||||
? "bg-slate-200 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
>
|
||||
Torrents
|
||||
</a>
|
||||
</nav>
|
||||
<div className="mt-4">
|
||||
{activeTab === "torrents" ? (
|
||||
<TorrentDownloads data={torrentDetails} />
|
||||
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
|
||||
<AirDCPPBundles data={bundles.data} />
|
||||
) : (
|
||||
<p>No DC++ bundles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsPanel;
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import React, { ReactElement } from "react";
|
||||
import { Form, Field, FieldRenderProps } from "react-final-form";
|
||||
import arrayMutators from "final-form-arrays";
|
||||
import { FieldArray } from "react-final-form-arrays";
|
||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
export const EditMetadataPanel = (props): ReactElement => {
|
||||
const validate = async () => {};
|
||||
interface EditMetadataPanelProps {
|
||||
data: {
|
||||
name?: string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Sliding panel form for manually editing comic metadata fields. */
|
||||
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
|
||||
const onSubmit = async () => {};
|
||||
|
||||
const { data } = props;
|
||||
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<AsyncSelectPaginate
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
// const rawFileDetails = useSelector(
|
||||
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||
// );
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
mutators={{
|
||||
...arrayMutators,
|
||||
}}
|
||||
mutators={{ ...arrayMutators }}
|
||||
render={({
|
||||
handleSubmit,
|
||||
form: {
|
||||
mutators: { push, pop },
|
||||
}, // injected from final-form-arrays above
|
||||
pristine,
|
||||
form,
|
||||
submitting,
|
||||
values,
|
||||
},
|
||||
}) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Issue Name */}
|
||||
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
<p className="text-xs">Do not enter the first zero</p>
|
||||
</div>
|
||||
<div>
|
||||
{/* year */}
|
||||
<div className="text-sm">Issue Year</div>
|
||||
<Field
|
||||
name="issue_year"
|
||||
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* page count */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mt-2">
|
||||
<label className="text-sm">Description</label>
|
||||
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label">
|
||||
@@ -129,6 +112,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
className="input"
|
||||
placeholder="SKU"
|
||||
/>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-barcode"></i>
|
||||
</span>
|
||||
@@ -145,6 +129,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
className="input"
|
||||
placeholder="UPC Code"
|
||||
/>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-box"></i>
|
||||
</span>
|
||||
@@ -153,7 +138,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* Publisher */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -167,6 +152,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"publisher"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-print mr-2"></i> Publisher
|
||||
</div>
|
||||
@@ -190,6 +176,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"story_arc"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-book-open mr-2"></i> Story Arc
|
||||
</div>
|
||||
@@ -213,6 +200,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"series"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-layer-group mr-2"></i> Series
|
||||
</div>
|
||||
@@ -224,7 +212,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* team credits */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -267,6 +255,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={`${name}.creator`}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fa-solid fa-ghost"></i> Creator
|
||||
</div>
|
||||
@@ -282,6 +271,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={`${name}.role`}
|
||||
metronResource={"role"}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fa-solid fa-key"></i> Role
|
||||
</div>
|
||||
@@ -290,6 +280,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span
|
||||
className="icon is-danger mt-2"
|
||||
onClick={() => fields.remove(index)}
|
||||
@@ -302,7 +293,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
))
|
||||
}
|
||||
</FieldArray>
|
||||
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
|
||||
interface MatchResultProps {
|
||||
matchData: any;
|
||||
comicObjectId: string;
|
||||
queryClient?: any;
|
||||
onMatchApplied?: () => void;
|
||||
}
|
||||
|
||||
const handleBrokenImage = (e) => {
|
||||
@@ -16,14 +19,33 @@ const handleBrokenImage = (e) => {
|
||||
|
||||
export const MatchResult = (props: MatchResultProps) => {
|
||||
const applyCVMatch = async (match, comicObjectId) => {
|
||||
return await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate and refetch the comic book metadata
|
||||
if (props.queryClient) {
|
||||
await props.queryClient.invalidateQueries({
|
||||
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Call the callback to close panel and switch tabs
|
||||
if (props.onMatchApplied) {
|
||||
props.onMatchApplied();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error applying ComicVine match:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import {
|
||||
RawFileDetails as RawFileDetailsType,
|
||||
InferredMetadata,
|
||||
} from "../../graphql/generated";
|
||||
|
||||
export const RawFileDetails = (props): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data;
|
||||
type RawFileDetailsProps = {
|
||||
data?: {
|
||||
rawFileDetails?: RawFileDetailsType;
|
||||
inferredMetadata?: InferredMetadata;
|
||||
createdAt?: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-2xl ml-5">
|
||||
<div className="px-4 sm:px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xl">{rawFileDetails.name}</span>
|
||||
<span className="text-xl">{rawFileDetails?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
@@ -22,10 +34,10 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
{rawFileDetails?.containedIn}
|
||||
{"/"}
|
||||
{rawFileDetails?.name}
|
||||
{rawFileDetails?.extension}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
@@ -33,10 +45,10 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
Inferred Issue Metadata
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
Series Name: {inferredMetadata.issue.name}
|
||||
{!isEmpty(inferredMetadata.issue.number) ? (
|
||||
Series Name: {inferredMetadata?.issue?.name}
|
||||
{!isEmpty(inferredMetadata?.issue?.number) ? (
|
||||
<span className="tag is-primary is-light">
|
||||
{inferredMetadata.issue.number}
|
||||
{inferredMetadata?.issue?.number}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
@@ -53,7 +65,7 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
{rawFileDetails?.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
@@ -70,7 +82,7 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
@@ -80,8 +92,12 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(created_at), "h aaaa")}
|
||||
{createdAt && isValid(parseISO(createdAt)) ? (
|
||||
<>
|
||||
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(createdAt), "h aaaa")}
|
||||
</>
|
||||
) : "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
@@ -98,30 +114,3 @@ export const RawFileDetails = (props): ReactElement => {
|
||||
};
|
||||
|
||||
export default RawFileDetails;
|
||||
|
||||
RawFileDetails.propTypes = {
|
||||
data: PropTypes.shape({
|
||||
rawFileDetails: PropTypes.shape({
|
||||
containedIn: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
fileSize: PropTypes.number,
|
||||
path: PropTypes.string,
|
||||
extension: PropTypes.string,
|
||||
mimeType: PropTypes.string,
|
||||
cover: PropTypes.shape({
|
||||
filePath: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
inferredMetadata: PropTypes.shape({
|
||||
issue: PropTypes.shape({
|
||||
year: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.number,
|
||||
subtitle: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
created_at: PropTypes.string,
|
||||
updated_at: PropTypes.string,
|
||||
}),
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
65
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
65
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
|
||||
|
||||
interface CVMatchesPanelProps {
|
||||
rawFileDetails?: RawFileDetails;
|
||||
inferredMetadata: InferredMetadata;
|
||||
comicVineMatches: any[];
|
||||
comicObjectId: string;
|
||||
queryClient: any;
|
||||
onMatchApplied: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sliding panel content for ComicVine match search.
|
||||
*
|
||||
* Renders a search form pre-populated from `rawFileDetails`, a preview of the
|
||||
* inferred issue being searched for, and a list of ComicVine match candidates
|
||||
* the user can apply to the comic.
|
||||
*
|
||||
* @param props.onMatchApplied - Called after the user selects and applies a match,
|
||||
* allowing the parent to close the panel and refresh state.
|
||||
*/
|
||||
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
queryClient,
|
||||
onMatchApplied,
|
||||
}) => (
|
||||
<>
|
||||
<div>
|
||||
<ComicVineSearchForm data={rawFileDetails} />
|
||||
</div>
|
||||
|
||||
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||
<p className="">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<>
|
||||
<span className="">{inferredMetadata.issue?.name} </span>
|
||||
<span className=""> # {inferredMetadata.issue?.number} </span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
queryClient,
|
||||
onMatchApplied,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
type EditMetadataPanelWrapperProps = {
|
||||
rawFileDetails?: RawFileDetails;
|
||||
};
|
||||
|
||||
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
||||
rawFileDetails,
|
||||
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import React, { ReactElement, Suspense, useState } from "react";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const TabControls = (props): ReactElement => {
|
||||
const { filteredTabs, downloadCount } = props;
|
||||
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
||||
const [active, setActive] = useState(filteredTabs[0].id);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const currentActive = activeTab !== undefined ? activeTab : active;
|
||||
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -14,12 +18,12 @@ export const TabControls = (props): ReactElement => {
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
currentActive === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
onClick={() => handleSetActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<>
|
||||
@@ -43,9 +47,13 @@ export const TabControls = (props): ReactElement => {
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
})}
|
||||
<Suspense fallback={null}>
|
||||
{filteredTabs.map(({ id, content }) => (
|
||||
<React.Fragment key={id}>
|
||||
{currentActive === id ? content : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,34 +14,41 @@ import { useStore } from "../../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
||||
|
||||
export const ArchiveOperations = (props): ReactElement => {
|
||||
export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
||||
const { data } = props;
|
||||
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
const getSocket = useStore((state) => state.getSocket);
|
||||
const queryClient = useQueryClient();
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
// current image
|
||||
const [currentImage, setCurrentImage] = useState([]);
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
|
||||
const [currentImage, setCurrentImage] = useState<string>("");
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
|
||||
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||
useState(false);
|
||||
const constructImagePaths = (data): Array<string> => {
|
||||
const constructImagePaths = (data: string[]): Array<string> => {
|
||||
return data?.map((path: string) =>
|
||||
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
|
||||
);
|
||||
};
|
||||
|
||||
// Listen to the uncompression complete event and orchestrate the final payload
|
||||
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
|
||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||
});
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
if (!socket) return;
|
||||
|
||||
const handleUncompressionComplete = (data: any) => {
|
||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||
};
|
||||
|
||||
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||
|
||||
return () => {
|
||||
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||
};
|
||||
}, [getSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -58,7 +65,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
},
|
||||
transformResponse: async (responseData) => {
|
||||
const parsedData = JSON.parse(responseData);
|
||||
const paths = parsedData.map((pathObject) => {
|
||||
const paths = parsedData.map((pathObject: any) => {
|
||||
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
|
||||
});
|
||||
const uncompressedArchive = constructImagePaths(paths);
|
||||
@@ -70,8 +77,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching uncompressed archive:", error);
|
||||
// Handle error if necessary
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
fetchUncompressedArchive();
|
||||
@@ -125,13 +131,15 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
if (isSuccess && shouldRefetchComicBookData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||
setShouldRefetchComicBookData(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isSuccess && shouldRefetchComicBookData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||
setShouldRefetchComicBookData(false);
|
||||
}
|
||||
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||
imageAnalysis: {
|
||||
content: () => {
|
||||
return (
|
||||
@@ -143,7 +151,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
</pre>
|
||||
) : null}
|
||||
<pre className="font-hasklig mt-3 text-sm">
|
||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -152,7 +160,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
};
|
||||
|
||||
// sliding panel handlers
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||
setSlidingPanelContentId("imageAnalysis");
|
||||
analyzeImage(imageFilePath);
|
||||
setCurrentImage(imageFilePath);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { isUndefined } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const ComicInfoXML = (data): ReactElement => {
|
||||
export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
||||
const { json } = data;
|
||||
return (
|
||||
<div className="flex md:w-4/5 lg:w-78">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||
<div className="flex w-3/4">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
|
||||
<dt>
|
||||
<p className="text-lg">{json.series[0]}</p>
|
||||
<p className="text-lg">{json.series?.[0]}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
published by{" "}
|
||||
<span className="underline">
|
||||
{json.publisher[0]}
|
||||
{json.publisher?.[0]}
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
</dd>
|
||||
@@ -30,18 +30,20 @@ export const ComicInfoXML = (data): ReactElement => {
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
<dd className="my-2">
|
||||
{/* Genre */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{/* Genre */}
|
||||
{!isUndefined(json.genre) && (
|
||||
<dd className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</dd>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<dd className="my-1">
|
||||
@@ -52,12 +54,14 @@ export const ComicInfoXML = (data): ReactElement => {
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dd>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
{!isUndefined(json.notes) && (
|
||||
<dd>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
522
src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx
Normal file
522
src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React, { ReactElement, useMemo, useState } from "react"
|
||||
import { Drawer } from "vaul"
|
||||
import { FIELD_CONFIG, FIELD_GROUPS } from "./reconciler.fieldConfig"
|
||||
import {
|
||||
useReconciler,
|
||||
SourceKey,
|
||||
SOURCE_LABELS,
|
||||
RawSourcedMetadata,
|
||||
RawInferredMetadata,
|
||||
CanonicalRecord,
|
||||
} from "./useReconciler"
|
||||
|
||||
// ── Source styling ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_BADGE: Record<SourceKey, string> = {
|
||||
comicvine: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
metron: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
|
||||
gcd: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
|
||||
locg: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
|
||||
comicInfo: "bg-slate-100 text-slate-700 dark:bg-slate-700/60 dark:text-slate-300",
|
||||
inferredMetadata: "bg-gray-100 text-gray-700 dark:bg-gray-700/60 dark:text-gray-300",
|
||||
}
|
||||
|
||||
const SOURCE_SELECTED: Record<SourceKey, string> = {
|
||||
comicvine: "ring-2 ring-blue-400 bg-blue-50 dark:bg-blue-900/20",
|
||||
metron: "ring-2 ring-purple-400 bg-purple-50 dark:bg-purple-900/20",
|
||||
gcd: "ring-2 ring-orange-400 bg-orange-50 dark:bg-orange-900/20",
|
||||
locg: "ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/20",
|
||||
comicInfo: "ring-2 ring-slate-400 bg-slate-50 dark:bg-slate-700/40",
|
||||
inferredMetadata: "ring-2 ring-gray-400 bg-gray-50 dark:bg-gray-700/40",
|
||||
}
|
||||
|
||||
/** Abbreviated source names for compact badge display. */
|
||||
const SOURCE_SHORT: Record<SourceKey, string> = {
|
||||
comicvine: "CV",
|
||||
metron: "Metron",
|
||||
gcd: "GCD",
|
||||
locg: "LoCG",
|
||||
comicInfo: "XML",
|
||||
inferredMetadata: "Local",
|
||||
}
|
||||
|
||||
const SOURCE_ORDER: SourceKey[] = [
|
||||
"comicvine", "metron", "gcd", "locg", "comicInfo", "inferredMetadata",
|
||||
]
|
||||
|
||||
type FilterMode = "all" | "conflicts" | "unresolved"
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReconcilerDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sourcedMetadata: RawSourcedMetadata
|
||||
inferredMetadata?: RawInferredMetadata
|
||||
onSave: (record: CanonicalRecord) => void
|
||||
}
|
||||
|
||||
// ── Scalar cell ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ScalarCellProps {
|
||||
value: string | null
|
||||
isSelected: boolean
|
||||
isImage: boolean
|
||||
isLongtext: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ScalarCell({ value, isSelected, isImage, isLongtext, onClick }: ScalarCellProps): ReactElement {
|
||||
if (!value) {
|
||||
return <span className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block">—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${SOURCE_SELECTED[/* filled by parent */ "comicvine"]}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
|
||||
/>
|
||||
) : (
|
||||
<span className={`block text-slate-700 dark:text-slate-300 ${isLongtext ? "line-clamp-3 whitespace-normal" : "truncate"}`}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReconcilerDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourcedMetadata,
|
||||
inferredMetadata,
|
||||
onSave,
|
||||
}: ReconcilerDrawerProps): ReactElement {
|
||||
const [filter, setFilter] = useState<FilterMode>("all")
|
||||
|
||||
const {
|
||||
state,
|
||||
unresolvedCount,
|
||||
canonicalRecord,
|
||||
selectScalar,
|
||||
toggleItem,
|
||||
setBaseSource,
|
||||
reset,
|
||||
} = useReconciler(sourcedMetadata, inferredMetadata)
|
||||
|
||||
// Derive which sources actually contributed data
|
||||
const activeSources = useMemo<SourceKey[]>(() => {
|
||||
const seen = new Set<SourceKey>()
|
||||
for (const fieldState of Object.values(state)) {
|
||||
if (fieldState.kind === "scalar") {
|
||||
for (const c of fieldState.candidates) seen.add(c.source)
|
||||
} else if (fieldState.kind === "array" || fieldState.kind === "credits") {
|
||||
for (const item of fieldState.items) seen.add((item as { source: SourceKey }).source)
|
||||
}
|
||||
}
|
||||
return SOURCE_ORDER.filter((s) => seen.has(s))
|
||||
}, [state])
|
||||
|
||||
// Grid: 180px label + one equal column per active source
|
||||
const gridCols = `180px repeat(${Math.max(activeSources.length, 1)}, minmax(0, 1fr))`
|
||||
|
||||
function shouldShow(fieldKey: string): boolean {
|
||||
const fs = state[fieldKey]
|
||||
if (!fs) return false
|
||||
if (filter === "all") return true
|
||||
if (filter === "conflicts") {
|
||||
if (fs.kind === "scalar") return fs.candidates.length > 1
|
||||
if (fs.kind === "array" || fs.kind === "credits") {
|
||||
const srcs = new Set((fs.items as Array<{ source: SourceKey }>).map((i) => i.source))
|
||||
return srcs.size > 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
// unresolved
|
||||
return (
|
||||
fs.kind === "scalar" &&
|
||||
fs.candidates.length > 1 &&
|
||||
fs.selectedSource === null &&
|
||||
fs.userValue === undefined
|
||||
)
|
||||
}
|
||||
|
||||
const allResolved = unresolvedCount === 0
|
||||
|
||||
return (
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/50 z-40" />
|
||||
<Drawer.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900 outline-none"
|
||||
>
|
||||
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex-none border-b border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
{/* Title + controls */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<i className="icon-[solar--refresh-circle-outline] w-5 h-5 text-slate-500 dark:text-slate-400" />
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
||||
Reconcile Metadata
|
||||
</span>
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{unresolvedCount} unresolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter pill */}
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 gap-0.5">
|
||||
{(["all", "conflicts", "unresolved"] as FilterMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setFilter(mode)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors capitalize ${
|
||||
filter === mode
|
||||
? "bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
title="Reset all selections"
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<i className="icon-[solar--close-square-outline] w-5 h-5 block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source column headers */}
|
||||
<div
|
||||
className="px-4 pb-3"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols, gap: "8px" }}
|
||||
>
|
||||
<div className="text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider flex items-end pb-0.5">
|
||||
Field
|
||||
</div>
|
||||
{activeSources.map((src) => (
|
||||
<div key={src} className="flex flex-col gap-1.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded w-fit ${SOURCE_BADGE[src]}`}>
|
||||
{SOURCE_LABELS[src]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setBaseSource(src)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-left transition-colors"
|
||||
>
|
||||
Use all ↓
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{FIELD_GROUPS.map((group) => {
|
||||
const fieldsInGroup = Object.entries(FIELD_CONFIG)
|
||||
.filter(([, cfg]) => cfg.group === group)
|
||||
.filter(([key]) => shouldShow(key))
|
||||
|
||||
if (fieldsInGroup.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={group}>
|
||||
{/* Group sticky header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-2 bg-slate-50 dark:bg-slate-800/90 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{group}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Field rows */}
|
||||
{fieldsInGroup.map(([fieldKey, fieldCfg]) => {
|
||||
const fs = state[fieldKey]
|
||||
if (!fs) return null
|
||||
|
||||
const isUnresolved =
|
||||
fs.kind === "scalar" &&
|
||||
fs.candidates.length > 1 &&
|
||||
fs.selectedSource === null &&
|
||||
fs.userValue === undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldKey}
|
||||
className={`border-b border-slate-100 dark:border-slate-800/60 transition-colors ${
|
||||
isUnresolved ? "bg-amber-50/50 dark:bg-amber-950/20" : ""
|
||||
}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: "8px",
|
||||
padding: "10px 16px",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{/* Label column */}
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pr-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">
|
||||
{fieldCfg.label}
|
||||
</span>
|
||||
{fieldCfg.comicInfoKey && (
|
||||
<span className="text-xs text-slate-400 font-mono leading-none">
|
||||
{fieldCfg.comicInfoKey}
|
||||
</span>
|
||||
)}
|
||||
{isUnresolved && (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-3 h-3" />
|
||||
conflict
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content — varies by kind */}
|
||||
{fs.kind === "scalar" ? (
|
||||
// One cell per active source
|
||||
activeSources.map((src) => {
|
||||
const candidate = fs.candidates.find((c) => c.source === src)
|
||||
const isSelected = fs.selectedSource === src
|
||||
|
||||
// For selected state we need the source-specific color
|
||||
const selectedClass = isSelected ? SOURCE_SELECTED[src] : ""
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<span
|
||||
key={src}
|
||||
className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => selectScalar(fieldKey, src)}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${selectedClass}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{fieldCfg.renderAs === "image" ? (
|
||||
<img
|
||||
src={candidate.value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`block text-slate-700 dark:text-slate-300 ${
|
||||
fieldCfg.renderAs === "longtext"
|
||||
? "line-clamp-3 whitespace-normal text-xs leading-relaxed"
|
||||
: "truncate"
|
||||
}`}
|
||||
>
|
||||
{candidate.value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : fs.kind === "array" ? (
|
||||
// Merged list spanning all source columns
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{item.displayValue}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : fs.kind === "credits" ? (
|
||||
// Credits spanning all source columns
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-2 px-2 py-1.5 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">·</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 text-xs">
|
||||
{item.role}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-xs px-1.5 py-0.5 rounded font-medium flex-none ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// GTIN and other complex types
|
||||
<div
|
||||
className="pt-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm italic">
|
||||
Structured field — editor coming soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty state when filter hides everything */}
|
||||
{FIELD_GROUPS.every((group) =>
|
||||
Object.entries(FIELD_CONFIG)
|
||||
.filter(([, cfg]) => cfg.group === group)
|
||||
.every(([key]) => !shouldShow(key)),
|
||||
) && (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-slate-400 dark:text-slate-500">
|
||||
<i className="icon-[solar--check-circle-bold] w-10 h-10 text-green-400" />
|
||||
<span className="text-sm">
|
||||
{filter === "unresolved" ? "No unresolved conflicts" : "No fields match the current filter"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex-none border-t border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between bg-white dark:bg-slate-900">
|
||||
<div className="text-sm">
|
||||
{allResolved ? (
|
||||
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<i className="icon-[solar--check-circle-bold] w-4 h-4" />
|
||||
All conflicts resolved
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-4 h-4" />
|
||||
{unresolvedCount} field{unresolvedCount !== 1 ? "s" : ""} still need a value
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave(canonicalRecord)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={!allResolved}
|
||||
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
||||
allResolved
|
||||
? "bg-green-600 text-white hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600"
|
||||
: "bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Save Canonical Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,201 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useMemo, useState } from "react";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import ComicVineDetails from "../ComicVineDetails";
|
||||
import { ReconcilerDrawer } from "./ReconcilerDrawer";
|
||||
import { fetcher } from "../../../graphql/fetcher";
|
||||
import { useGetComicByIdQuery } from "../../../graphql/generated";
|
||||
import type { CanonicalRecord } from "./useReconciler";
|
||||
|
||||
export const VolumeInformation = (props): ReactElement => {
|
||||
interface ComicVineMetadata {
|
||||
volumeInformation?: Record<string, unknown>;
|
||||
name?: string;
|
||||
number?: string;
|
||||
resource_type?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface SourcedMetadata {
|
||||
comicvine?: ComicVineMetadata;
|
||||
locg?: Record<string, unknown>;
|
||||
comicInfo?: unknown;
|
||||
metron?: unknown;
|
||||
gcd?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface VolumeInformationData {
|
||||
id?: string;
|
||||
sourcedMetadata?: SourcedMetadata;
|
||||
inferredMetadata?: { issue?: unknown };
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VolumeInformationProps {
|
||||
data: VolumeInformationData;
|
||||
onReconcile?: () => void;
|
||||
}
|
||||
|
||||
const SET_METADATA_FIELD = `
|
||||
mutation SetMetadataField($comicId: ID!, $field: String!, $value: String!) {
|
||||
setMetadataField(comicId: $comicId, field: $field, value: $value) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
|
||||
const SOURCED_METADATA_KEYS = [
|
||||
"comicvine",
|
||||
"locg",
|
||||
"comicInfo",
|
||||
"metron",
|
||||
"gcd",
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
comicvine: "ComicVine",
|
||||
locg: "League of Comic Geeks",
|
||||
comicInfo: "ComicInfo.xml",
|
||||
metron: "Metron",
|
||||
gcd: "Grand Comics Database",
|
||||
inferredMetadata: "Local File",
|
||||
};
|
||||
|
||||
const SOURCE_ICONS: Record<string, string> = {
|
||||
comicvine: "icon-[solar--database-bold]",
|
||||
locg: "icon-[solar--users-group-rounded-outline]",
|
||||
comicInfo: "icon-[solar--file-text-outline]",
|
||||
metron: "icon-[solar--planet-outline]",
|
||||
gcd: "icon-[solar--book-outline]",
|
||||
inferredMetadata: "icon-[solar--folder-outline]",
|
||||
};
|
||||
|
||||
const MetadataSourceChips = ({
|
||||
sources,
|
||||
onOpenReconciler,
|
||||
}: {
|
||||
sources: string[];
|
||||
onOpenReconciler: () => void;
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
||||
{sources.length} metadata sources detected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{sources.map((source) => (
|
||||
<span
|
||||
key={source}
|
||||
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
||||
>
|
||||
<i
|
||||
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
||||
/>
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={onOpenReconciler}
|
||||
>
|
||||
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
||||
Reconcile sources
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays volume metadata for a comic.
|
||||
*
|
||||
* - When multiple sources are present, renders a chip bar listing each source
|
||||
* with a "Reconcile sources" action to merge them.
|
||||
* - When exactly one source is present and it is ComicVine, renders the full
|
||||
* ComicVine detail panel directly.
|
||||
*
|
||||
* @param props.data - Comic data containing sourced and inferred metadata.
|
||||
* @param props.onReconcile - Called when the user triggers source reconciliation.
|
||||
*/
|
||||
export const VolumeInformation = (
|
||||
props: VolumeInformationProps,
|
||||
): ReactElement => {
|
||||
const { data } = props;
|
||||
const [isReconcilerOpen, setReconcilerOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: saveCanonical } = useMutation({
|
||||
mutationFn: async (record: CanonicalRecord) => {
|
||||
const saves = Object.entries(record)
|
||||
.filter(([, fv]) => fv != null)
|
||||
.map(([field, fv]) => ({
|
||||
field,
|
||||
value:
|
||||
typeof fv!.value === "string"
|
||||
? fv!.value
|
||||
: JSON.stringify(fv!.value),
|
||||
}));
|
||||
await Promise.all(
|
||||
saves.map(({ field, value }) =>
|
||||
fetcher<unknown, { comicId: string; field: string; value: string }>(
|
||||
SET_METADATA_FIELD,
|
||||
{ comicId: data.id ?? "", field, value },
|
||||
)(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: useGetComicByIdQuery.getKey({ id: data.id ?? "" }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const presentSources = useMemo(() => {
|
||||
const sources = SOURCED_METADATA_KEYS.filter((key) => {
|
||||
const val = (data?.sourcedMetadata ?? {})[key];
|
||||
if (isNil(val) || isEmpty(val)) return false;
|
||||
// locg returns an object even when empty; require at least one non-null value
|
||||
if (key === "locg")
|
||||
return Object.values(val as Record<string, unknown>).some(
|
||||
(v) => !isNil(v) && v !== "",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
if (
|
||||
!isNil(data?.inferredMetadata?.issue) &&
|
||||
!isEmpty(data?.inferredMetadata?.issue)
|
||||
) {
|
||||
sources.push("inferredMetadata");
|
||||
}
|
||||
return sources;
|
||||
}, [data?.sourcedMetadata, data?.inferredMetadata]);
|
||||
|
||||
return (
|
||||
<div key={1}>
|
||||
<ComicVineDetails
|
||||
data={data.sourcedMetadata.comicvine}
|
||||
updatedAt={data.updatedAt}
|
||||
{presentSources.length > 1 && (
|
||||
<MetadataSourceChips
|
||||
sources={presentSources}
|
||||
onOpenReconciler={() => setReconcilerOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{presentSources.length === 1 &&
|
||||
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
||||
<ComicVineDetails
|
||||
data={data.sourcedMetadata.comicvine}
|
||||
updatedAt={data.updatedAt}
|
||||
/>
|
||||
)}
|
||||
<ReconcilerDrawer
|
||||
open={isReconcilerOpen}
|
||||
onOpenChange={setReconcilerOpen}
|
||||
sourcedMetadata={(data.sourcedMetadata ?? {}) as import("./useReconciler").RawSourcedMetadata}
|
||||
inferredMetadata={data.inferredMetadata as import("./useReconciler").RawInferredMetadata | undefined}
|
||||
onSave={saveCanonical}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
285
src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts
Normal file
285
src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* UI field configuration for the metadata reconciler.
|
||||
*
|
||||
* Each entry maps a CanonicalMetadata field key to:
|
||||
* - label Display name shown in the reconciler table
|
||||
* - group Which section the field belongs to
|
||||
* - renderAs How the field's cell is rendered (drives component selection)
|
||||
* - comicInfoKey The ComicInfo.xml v1 key this field exports to, or null if
|
||||
* the field has no v1 equivalent (shown with a badge in the UI)
|
||||
*
|
||||
* The order of entries within each group controls row order in the table.
|
||||
*/
|
||||
|
||||
export type RenderType =
|
||||
| "scalar" // Single string/number — click to select
|
||||
| "date" // ISO date string — click to select
|
||||
| "longtext" // Multi-line text — click to select, expandable preview
|
||||
| "image" // Cover image — thumbnail grid picker
|
||||
| "array" // Flat list of strings with source badges
|
||||
| "arcs" // [{name, number}] — arc name + position number
|
||||
| "universes" // [{name, designation}] — universe name + designation
|
||||
| "credits" // [{name, role}] — role-grouped, toggleable list
|
||||
| "seriesInfo" // Structured series object — rendered as sub-fields
|
||||
| "prices" // [{country, amount, currency}]
|
||||
| "gtin" // {isbn, upc}
|
||||
| "reprints" // [{description}]
|
||||
| "urls" // [{url, primary}]
|
||||
| "externalIDs" // [{source, externalId, primary}]
|
||||
|
||||
export type FieldGroup =
|
||||
| "Identity"
|
||||
| "Series"
|
||||
| "Publication"
|
||||
| "Content"
|
||||
| "Credits"
|
||||
| "Classification"
|
||||
| "Physical"
|
||||
| "Commercial"
|
||||
| "External"
|
||||
|
||||
/** Ordered list of groups — controls section order in the reconciler table. */
|
||||
export const FIELD_GROUPS: FieldGroup[] = [
|
||||
"Identity",
|
||||
"Series",
|
||||
"Publication",
|
||||
"Content",
|
||||
"Credits",
|
||||
"Classification",
|
||||
"Physical",
|
||||
"Commercial",
|
||||
"External",
|
||||
]
|
||||
|
||||
export interface FieldConfig {
|
||||
label: string
|
||||
group: FieldGroup
|
||||
renderAs: RenderType
|
||||
/**
|
||||
* ComicInfo.xml v1 key this field maps to on export.
|
||||
* null means the field is not exported to ComicInfo v1.
|
||||
*/
|
||||
comicInfoKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Master field registry for the reconciler.
|
||||
* Keys match CanonicalMetadata field names from the core-service GraphQL schema.
|
||||
*/
|
||||
export const FIELD_CONFIG: Record<string, FieldConfig> = {
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
title: {
|
||||
label: "Title",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
series: {
|
||||
label: "Series",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "series",
|
||||
},
|
||||
issueNumber: {
|
||||
label: "Issue Number",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "number",
|
||||
},
|
||||
volume: {
|
||||
label: "Volume",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
collectionTitle: {
|
||||
label: "Collection Title",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Series ────────────────────────────────────────────────────────────────
|
||||
seriesInfo: {
|
||||
label: "Series Info",
|
||||
group: "Series",
|
||||
renderAs: "seriesInfo",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Publication ───────────────────────────────────────────────────────────
|
||||
publisher: {
|
||||
label: "Publisher",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "publisher",
|
||||
},
|
||||
imprint: {
|
||||
label: "Imprint",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
coverDate: {
|
||||
label: "Cover Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
storeDate: {
|
||||
label: "Store Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
publicationDate: {
|
||||
label: "Publication Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
language: {
|
||||
label: "Language",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "languageiso",
|
||||
},
|
||||
|
||||
// ── Content ───────────────────────────────────────────────────────────────
|
||||
description: {
|
||||
label: "Description",
|
||||
group: "Content",
|
||||
renderAs: "longtext",
|
||||
comicInfoKey: "summary",
|
||||
},
|
||||
notes: {
|
||||
label: "Notes",
|
||||
group: "Content",
|
||||
renderAs: "longtext",
|
||||
comicInfoKey: "notes",
|
||||
},
|
||||
stories: {
|
||||
label: "Stories",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
storyArcs: {
|
||||
label: "Story Arcs",
|
||||
group: "Content",
|
||||
renderAs: "arcs",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
characters: {
|
||||
label: "Characters",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
teams: {
|
||||
label: "Teams",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
locations: {
|
||||
label: "Locations",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
universes: {
|
||||
label: "Universes",
|
||||
group: "Content",
|
||||
renderAs: "universes",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
coverImage: {
|
||||
label: "Cover Image",
|
||||
group: "Content",
|
||||
renderAs: "image",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Credits ───────────────────────────────────────────────────────────────
|
||||
creators: {
|
||||
label: "Credits",
|
||||
group: "Credits",
|
||||
renderAs: "credits",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Classification ────────────────────────────────────────────────────────
|
||||
genres: {
|
||||
label: "Genres",
|
||||
group: "Classification",
|
||||
renderAs: "array",
|
||||
comicInfoKey: "genre",
|
||||
},
|
||||
tags: {
|
||||
label: "Tags",
|
||||
group: "Classification",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
ageRating: {
|
||||
label: "Age Rating",
|
||||
group: "Classification",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Physical ──────────────────────────────────────────────────────────────
|
||||
pageCount: {
|
||||
label: "Page Count",
|
||||
group: "Physical",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "pagecount",
|
||||
},
|
||||
format: {
|
||||
label: "Format",
|
||||
group: "Physical",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Commercial ────────────────────────────────────────────────────────────
|
||||
prices: {
|
||||
label: "Prices",
|
||||
group: "Commercial",
|
||||
renderAs: "prices",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
gtin: {
|
||||
label: "ISBN / UPC",
|
||||
group: "Commercial",
|
||||
renderAs: "gtin",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
reprints: {
|
||||
label: "Reprints",
|
||||
group: "Commercial",
|
||||
renderAs: "reprints",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
communityRating: {
|
||||
label: "Community Rating",
|
||||
group: "Commercial",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── External ──────────────────────────────────────────────────────────────
|
||||
externalIDs: {
|
||||
label: "Source IDs",
|
||||
group: "External",
|
||||
renderAs: "externalIDs",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
urls: {
|
||||
label: "URLs",
|
||||
group: "External",
|
||||
renderAs: "urls",
|
||||
comicInfoKey: "web",
|
||||
},
|
||||
} as const
|
||||
745
src/client/components/ComicDetail/Tabs/useReconciler.ts
Normal file
745
src/client/components/ComicDetail/Tabs/useReconciler.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
import { useReducer, useMemo } from "react";
|
||||
import { isNil, isEmpty } from "lodash";
|
||||
|
||||
// ── Source keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SourceKey =
|
||||
| "comicvine"
|
||||
| "metron"
|
||||
| "gcd"
|
||||
| "locg"
|
||||
| "comicInfo"
|
||||
| "inferredMetadata";
|
||||
|
||||
export const SOURCE_LABELS: Record<SourceKey, string> = {
|
||||
comicvine: "ComicVine",
|
||||
metron: "Metron",
|
||||
gcd: "Grand Comics Database",
|
||||
locg: "League of Comic Geeks",
|
||||
comicInfo: "ComicInfo.xml",
|
||||
inferredMetadata: "Local File",
|
||||
};
|
||||
|
||||
// ── Candidate types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** One source's value for a scalar field. Multiple candidates for the same field = conflict. */
|
||||
export interface ScalarCandidate {
|
||||
source: SourceKey;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** One item in an array field (characters, genres, arcs…). Pre-selected; user may deselect. */
|
||||
export interface ArrayItem {
|
||||
/** Lowercase dedup key. */
|
||||
itemKey: string;
|
||||
displayValue: string;
|
||||
/** Raw value passed through to the canonical record. */
|
||||
rawValue: unknown;
|
||||
source: SourceKey;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/** One person credit. Dedup key is `"${name}:${role}"` (lowercased). */
|
||||
export interface CreditItem {
|
||||
itemKey: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
role: string;
|
||||
source: SourceKey;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ── Per-field state ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unresolved when `selectedSource === null` and `userValue` is absent. */
|
||||
interface ScalarFieldState {
|
||||
kind: "scalar";
|
||||
candidates: ScalarCandidate[];
|
||||
selectedSource: SourceKey | null;
|
||||
/** User-typed override; takes precedence over any source value. */
|
||||
userValue?: string;
|
||||
}
|
||||
|
||||
interface ArrayFieldState {
|
||||
kind: "array";
|
||||
items: ArrayItem[];
|
||||
}
|
||||
|
||||
interface CreditsFieldState {
|
||||
kind: "credits";
|
||||
items: CreditItem[];
|
||||
}
|
||||
|
||||
interface GTINFieldState {
|
||||
kind: "gtin";
|
||||
candidates: Array<{ source: SourceKey; isbn?: string; upc?: string }>;
|
||||
selectedIsbnSource: SourceKey | null;
|
||||
selectedUpcSource: SourceKey | null;
|
||||
}
|
||||
|
||||
type FieldState = ScalarFieldState | ArrayFieldState | CreditsFieldState | GTINFieldState;
|
||||
|
||||
/** Full reconciler state — one entry per field that has data from at least one source. */
|
||||
export type ReconcilerState = Record<string, FieldState>;
|
||||
|
||||
// ── Raw source data ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw metadata payloads keyed by source, as stored on the comic document. */
|
||||
export interface RawSourcedMetadata {
|
||||
comicvine?: Record<string, unknown>;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
metron?: unknown;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
gcd?: unknown;
|
||||
locg?: Record<string, unknown>;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
comicInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Metadata inferred from the local file name / path. */
|
||||
export interface RawInferredMetadata {
|
||||
issue?: {
|
||||
name?: string;
|
||||
number?: number;
|
||||
year?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function safeString(v: unknown): string | null {
|
||||
if (isNil(v) || v === "") return null;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** xml2js with `normalizeTags` wraps every value in a single-element array. */
|
||||
function xmlVal(obj: Record<string, unknown>, key: string): string | null {
|
||||
const arr = obj[key];
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
return safeString(arr[0]);
|
||||
}
|
||||
|
||||
/** Parse a JSON string if it hasn't been parsed yet. */
|
||||
function ensureParsed(v: unknown): Record<string, unknown> | null {
|
||||
if (isNil(v)) return null;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof v === "object") return v as Record<string, unknown>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeScalarCandidate(
|
||||
source: SourceKey,
|
||||
value: unknown,
|
||||
): ScalarCandidate | undefined {
|
||||
const val = safeString(value);
|
||||
return val ? { source, value: val } : undefined;
|
||||
}
|
||||
|
||||
function makeArrayItem(
|
||||
source: SourceKey,
|
||||
rawValue: unknown,
|
||||
displayValue: string,
|
||||
): ArrayItem {
|
||||
return {
|
||||
itemKey: displayValue.toLowerCase().trim(),
|
||||
displayValue,
|
||||
rawValue,
|
||||
source,
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCreditItem(
|
||||
source: SourceKey,
|
||||
name: string,
|
||||
role: string,
|
||||
id?: string,
|
||||
): CreditItem {
|
||||
return {
|
||||
itemKey: `${name.toLowerCase().trim()}:${role.toLowerCase().trim()}`,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
source,
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Source adapters ────────────────────────────────────────────────────────────
|
||||
|
||||
type AdapterResult = Partial<Record<string, ScalarCandidate | ArrayItem[] | CreditItem[]>>;
|
||||
|
||||
/**
|
||||
* Extract canonical fields from a ComicVine issue payload.
|
||||
* Volume info lives under `volumeInformation`; credits under `person_credits` etc.
|
||||
*/
|
||||
function fromComicVine(cv: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "comicvine";
|
||||
const vi = cv.volumeInformation as Record<string, unknown> | undefined;
|
||||
const img = cv.image as Record<string, unknown> | undefined;
|
||||
const publisher = vi?.publisher as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
title: makeScalarCandidate(s, cv.name),
|
||||
series: makeScalarCandidate(s, vi?.name),
|
||||
issueNumber: makeScalarCandidate(s, cv.issue_number),
|
||||
volume: makeScalarCandidate(s, vi?.id),
|
||||
description: makeScalarCandidate(s, cv.description),
|
||||
publisher: makeScalarCandidate(s, publisher?.name),
|
||||
coverDate: makeScalarCandidate(s, cv.cover_date),
|
||||
storeDate: makeScalarCandidate(s, cv.store_date),
|
||||
coverImage: makeScalarCandidate(s, img?.super_url ?? img?.small_url),
|
||||
characters: ((cv.character_credits as unknown[]) ?? [])
|
||||
.filter((c): c is Record<string, unknown> => !isNil(c))
|
||||
.map((c) => makeArrayItem(s, c, safeString(c.name) ?? "")),
|
||||
teams: ((cv.team_credits as unknown[]) ?? [])
|
||||
.filter((t): t is Record<string, unknown> => !isNil(t))
|
||||
.map((t) => makeArrayItem(s, t, safeString(t.name) ?? "")),
|
||||
locations: ((cv.location_credits as unknown[]) ?? [])
|
||||
.filter((l): l is Record<string, unknown> => !isNil(l))
|
||||
.map((l) => makeArrayItem(s, l, safeString(l.name) ?? "")),
|
||||
storyArcs: ((cv.story_arc_credits as unknown[]) ?? [])
|
||||
.filter((a): a is Record<string, unknown> => !isNil(a))
|
||||
.map((a) => makeArrayItem(s, a, safeString(a.name) ?? "")),
|
||||
creators: ((cv.person_credits as unknown[]) ?? [])
|
||||
.filter((p): p is Record<string, unknown> => !isNil(p))
|
||||
.map((p) =>
|
||||
makeCreditItem(s, safeString(p.name) ?? "", safeString(p.role) ?? ""),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract canonical fields from a Metron / MetronInfo payload.
|
||||
* Keys are PascalCase mirroring the MetronInfo XSD schema.
|
||||
*/
|
||||
function fromMetron(raw: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "metron";
|
||||
const series = raw.Series as Record<string, unknown> | undefined;
|
||||
const pub = raw.Publisher as Record<string, unknown> | undefined;
|
||||
|
||||
const nameList = (arr: unknown[]): ArrayItem[] =>
|
||||
arr
|
||||
.filter((x): x is Record<string, unknown> => !isNil(x))
|
||||
.map((x) => makeArrayItem(s, x, safeString(x.name) ?? ""));
|
||||
|
||||
return {
|
||||
title: makeScalarCandidate(s, (raw.Stories as unknown[])?.[0]),
|
||||
series: makeScalarCandidate(s, series?.Name),
|
||||
issueNumber: makeScalarCandidate(s, raw.Number),
|
||||
collectionTitle: makeScalarCandidate(s, raw.CollectionTitle),
|
||||
publisher: makeScalarCandidate(s, pub?.Name),
|
||||
imprint: makeScalarCandidate(s, pub?.Imprint),
|
||||
coverDate: makeScalarCandidate(s, raw.CoverDate),
|
||||
storeDate: makeScalarCandidate(s, raw.StoreDate),
|
||||
description: makeScalarCandidate(s, raw.Summary),
|
||||
notes: makeScalarCandidate(s, raw.Notes),
|
||||
ageRating: makeScalarCandidate(s, raw.AgeRating),
|
||||
pageCount: makeScalarCandidate(s, raw.PageCount),
|
||||
format: makeScalarCandidate(s, series?.Format),
|
||||
language: makeScalarCandidate(s, series?.lang),
|
||||
genres: nameList((raw.Genres as unknown[]) ?? []),
|
||||
tags: ((raw.Tags as unknown[]) ?? [])
|
||||
.filter((t) => !isNil(t))
|
||||
.map((t) => makeArrayItem(s, t, safeString(t) ?? "")),
|
||||
characters: nameList((raw.Characters as unknown[]) ?? []),
|
||||
teams: nameList((raw.Teams as unknown[]) ?? []),
|
||||
locations: nameList((raw.Locations as unknown[]) ?? []),
|
||||
universes: ((raw.Universes as unknown[]) ?? [])
|
||||
.filter((u): u is Record<string, unknown> => !isNil(u))
|
||||
.map((u) =>
|
||||
makeArrayItem(
|
||||
s,
|
||||
u,
|
||||
[u.Name, u.Designation].filter(Boolean).join(" — "),
|
||||
),
|
||||
),
|
||||
storyArcs: ((raw.Arcs as unknown[]) ?? [])
|
||||
.filter((a): a is Record<string, unknown> => !isNil(a))
|
||||
.map((a) =>
|
||||
makeArrayItem(
|
||||
s,
|
||||
a,
|
||||
[a.Name, a.Number ? `#${a.Number}` : null].filter(Boolean).join(" "),
|
||||
),
|
||||
),
|
||||
stories: ((raw.Stories as unknown[]) ?? [])
|
||||
.filter((t) => !isNil(t))
|
||||
.map((t) => makeArrayItem(s, t, safeString(t) ?? "")),
|
||||
creators: ((raw.Credits as unknown[]) ?? [])
|
||||
.filter((c): c is Record<string, unknown> => !isNil(c))
|
||||
.flatMap((c) => {
|
||||
const creator = c.Creator as Record<string, unknown> | undefined;
|
||||
const roles = (c.Roles as unknown[]) ?? [];
|
||||
return roles
|
||||
.filter((r): r is Record<string, unknown> => !isNil(r))
|
||||
.map((r) =>
|
||||
makeCreditItem(
|
||||
s,
|
||||
safeString(creator?.name) ?? "",
|
||||
safeString(r.name ?? r) ?? "",
|
||||
safeString(creator?.id) ?? undefined,
|
||||
),
|
||||
);
|
||||
}),
|
||||
reprints: ((raw.Reprints as unknown[]) ?? [])
|
||||
.filter((r) => !isNil(r))
|
||||
.map((r) => makeArrayItem(s, r, safeString(r) ?? "")),
|
||||
urls: ((raw.URLs as unknown[]) ?? [])
|
||||
.filter((u) => !isNil(u))
|
||||
.map((u) => makeArrayItem(s, u, safeString(u) ?? "")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract canonical fields from a ComicInfo.xml payload.
|
||||
* Values are xml2js-parsed with `normalizeTags` (each key wraps its value in a single-element array).
|
||||
* Genre is a comma-separated string; the web URL maps to `urls`.
|
||||
*/
|
||||
function fromComicInfo(ci: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "comicInfo";
|
||||
const webUrl = xmlVal(ci, "web");
|
||||
const genreItems: ArrayItem[] = (xmlVal(ci, "genre") ?? "")
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => makeArrayItem(s, g, g));
|
||||
|
||||
return {
|
||||
series: makeScalarCandidate(s, xmlVal(ci, "series")),
|
||||
issueNumber: makeScalarCandidate(s, xmlVal(ci, "number")),
|
||||
publisher: makeScalarCandidate(s, xmlVal(ci, "publisher")),
|
||||
description: makeScalarCandidate(s, xmlVal(ci, "summary")),
|
||||
notes: makeScalarCandidate(s, xmlVal(ci, "notes")),
|
||||
pageCount: makeScalarCandidate(s, xmlVal(ci, "pagecount")),
|
||||
language: makeScalarCandidate(s, xmlVal(ci, "languageiso")),
|
||||
urls: webUrl ? [makeArrayItem(s, webUrl, webUrl)] : [],
|
||||
genres: genreItems,
|
||||
};
|
||||
}
|
||||
|
||||
/** GCD free-text credit fields: field key → role name. */
|
||||
const GCD_CREDIT_FIELDS: Array<{ key: string; role: string }> = [
|
||||
{ key: "script", role: "Writer" },
|
||||
{ key: "pencils", role: "Penciller" },
|
||||
{ key: "inks", role: "Inker" },
|
||||
{ key: "colors", role: "Colorist" },
|
||||
{ key: "letters", role: "Letterer" },
|
||||
{ key: "editing", role: "Editor" },
|
||||
];
|
||||
|
||||
/** Split a GCD free-text credit string (semicolon-separated; strips bracketed annotations). */
|
||||
function splitGCDCreditString(raw: string): string[] {
|
||||
return raw
|
||||
.split(/;/)
|
||||
.map((name) => name.replace(/\[.*?\]/g, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Parse a GCD price string like "0.10 USD" or "10p". Returns null on failure. */
|
||||
function parseGCDPrice(
|
||||
raw: string,
|
||||
): { amount: number; currency: string } | null {
|
||||
const match = raw.trim().match(/^([\d.,]+)\s*([A-Z]{2,3}|p|¢|€|£|\$)?/);
|
||||
if (!match) return null;
|
||||
const amount = parseFloat(match[1].replace(",", "."));
|
||||
const currency = match[2] ?? "USD";
|
||||
if (isNaN(amount)) return null;
|
||||
return { amount, currency };
|
||||
}
|
||||
|
||||
function fromGCD(raw: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "gcd";
|
||||
const series = raw.series as Record<string, unknown> | undefined;
|
||||
const language = series?.language as Record<string, unknown> | undefined;
|
||||
const publisher = series?.publisher as Record<string, unknown> | undefined;
|
||||
const indiciaPublisher = raw.indicia_publisher as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const stories = (raw.stories as Record<string, unknown>[]) ?? [];
|
||||
const primaryStory = stories[0] ?? {};
|
||||
|
||||
const creditItems: CreditItem[] = [];
|
||||
if (raw.editing) {
|
||||
splitGCDCreditString(String(raw.editing)).forEach((name) =>
|
||||
creditItems.push(makeCreditItem(s, name, "Editor")),
|
||||
);
|
||||
}
|
||||
GCD_CREDIT_FIELDS.forEach(({ key, role }) => {
|
||||
const val = safeString(primaryStory[key]);
|
||||
if (!val) return;
|
||||
splitGCDCreditString(val).forEach((name) =>
|
||||
creditItems.push(makeCreditItem(s, name, role)),
|
||||
);
|
||||
});
|
||||
|
||||
const genreItems: ArrayItem[] = (safeString(primaryStory.genre) ?? "")
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => makeArrayItem(s, g, g));
|
||||
|
||||
const characterItems: ArrayItem[] = (
|
||||
safeString(primaryStory.characters) ?? ""
|
||||
)
|
||||
.split(/[;,]/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
.map((c) => makeArrayItem(s, c, c));
|
||||
|
||||
const storyTitles: ArrayItem[] = stories
|
||||
.map((st) => safeString(st.title))
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.map((t) => makeArrayItem(s, t, t));
|
||||
|
||||
const priceItems: ArrayItem[] = [];
|
||||
const priceStr = safeString(raw.price);
|
||||
if (priceStr) {
|
||||
const parsed = parseGCDPrice(priceStr);
|
||||
if (parsed) {
|
||||
priceItems.push(makeArrayItem(s, { ...parsed, country: "US" }, priceStr));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
series: makeScalarCandidate(s, series?.name),
|
||||
issueNumber: makeScalarCandidate(s, raw.number),
|
||||
title: makeScalarCandidate(s, raw.title ?? primaryStory.title),
|
||||
volume: makeScalarCandidate(s, raw.volume),
|
||||
// Prefer indicia publisher (as-printed) over series publisher
|
||||
publisher: makeScalarCandidate(s, indiciaPublisher?.name ?? publisher?.name),
|
||||
coverDate: makeScalarCandidate(s, raw.publication_date),
|
||||
storeDate: makeScalarCandidate(s, raw.on_sale_date ?? raw.key_date),
|
||||
pageCount: makeScalarCandidate(s, raw.page_count),
|
||||
notes: makeScalarCandidate(s, raw.notes),
|
||||
language: makeScalarCandidate(s, language?.code),
|
||||
ageRating: makeScalarCandidate(s, raw.rating),
|
||||
genres: genreItems,
|
||||
characters: characterItems,
|
||||
stories: storyTitles,
|
||||
creators: creditItems,
|
||||
prices: priceItems,
|
||||
};
|
||||
}
|
||||
|
||||
function fromLocg(locg: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "locg";
|
||||
return {
|
||||
title: makeScalarCandidate(s, locg.name),
|
||||
publisher: makeScalarCandidate(s, locg.publisher),
|
||||
description: makeScalarCandidate(s, locg.description),
|
||||
coverImage: makeScalarCandidate(s, locg.cover),
|
||||
communityRating: makeScalarCandidate(s, locg.rating),
|
||||
publicationDate: makeScalarCandidate(s, locg.publicationDate),
|
||||
};
|
||||
}
|
||||
|
||||
function fromInferred(inf: RawInferredMetadata["issue"]): AdapterResult {
|
||||
if (!inf) return {};
|
||||
const s: SourceKey = "inferredMetadata";
|
||||
return {
|
||||
title: makeScalarCandidate(s, inf.name),
|
||||
issueNumber: makeScalarCandidate(s, inf.number),
|
||||
volume: makeScalarCandidate(s, inf.year),
|
||||
};
|
||||
}
|
||||
|
||||
// ── State building ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge all adapter results directly into a `ReconcilerState`.
|
||||
* Array and credit items are deduplicated by `itemKey` using a Set (O(n)).
|
||||
* Scalar conflicts are auto-resolved when all sources agree on the same value.
|
||||
*/
|
||||
function buildState(
|
||||
sources: Partial<Record<SourceKey, AdapterResult>>,
|
||||
): ReconcilerState {
|
||||
const state: ReconcilerState = {};
|
||||
const scalarMap: Record<string, ScalarCandidate[]> = {};
|
||||
|
||||
for (const adapterResult of Object.values(sources)) {
|
||||
if (!adapterResult) continue;
|
||||
for (const [field, value] of Object.entries(adapterResult)) {
|
||||
if (!value) continue;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Presence of `role` distinguishes CreditItem[] from ArrayItem[].
|
||||
const isCredits = value.length > 0 && "role" in value[0];
|
||||
if (isCredits) {
|
||||
const prev = state[field];
|
||||
const existing: CreditItem[] =
|
||||
prev?.kind === "credits" ? prev.items : [];
|
||||
const seen = new Set(existing.map((i) => i.itemKey));
|
||||
const merged = [...existing];
|
||||
for (const item of value as CreditItem[]) {
|
||||
if (!seen.has(item.itemKey)) {
|
||||
seen.add(item.itemKey);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
state[field] = { kind: "credits", items: merged };
|
||||
} else {
|
||||
const prev = state[field];
|
||||
const existing: ArrayItem[] =
|
||||
prev?.kind === "array" ? prev.items : [];
|
||||
const seen = new Set(existing.map((i) => i.itemKey));
|
||||
const merged = [...existing];
|
||||
for (const item of value as ArrayItem[]) {
|
||||
if (!seen.has(item.itemKey)) {
|
||||
seen.add(item.itemKey);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
state[field] = { kind: "array", items: merged };
|
||||
}
|
||||
} else {
|
||||
(scalarMap[field] ??= []).push(value as ScalarCandidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, candidates] of Object.entries(scalarMap)) {
|
||||
const allAgree =
|
||||
candidates.length === 1 ||
|
||||
candidates.every((c) => c.value === candidates[0].value);
|
||||
state[field] = {
|
||||
kind: "scalar",
|
||||
candidates,
|
||||
selectedSource: allAgree ? candidates[0].source : null,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Reducer ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Action =
|
||||
| { type: "SELECT_SCALAR"; field: string; source: SourceKey }
|
||||
| { type: "SET_USER_VALUE"; field: string; value: string }
|
||||
| { type: "TOGGLE_ITEM"; field: string; itemKey: string; selected: boolean }
|
||||
| { type: "SET_BASE_SOURCE"; source: SourceKey }
|
||||
| { type: "RESET"; initial: ReconcilerState };
|
||||
|
||||
function reducer(state: ReconcilerState, action: Action): ReconcilerState {
|
||||
switch (action.type) {
|
||||
case "SELECT_SCALAR": {
|
||||
const field = state[action.field];
|
||||
if (field?.kind !== "scalar") return state;
|
||||
return {
|
||||
...state,
|
||||
[action.field]: {
|
||||
...field,
|
||||
selectedSource: action.source,
|
||||
userValue: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "SET_USER_VALUE": {
|
||||
const field = state[action.field];
|
||||
if (field?.kind !== "scalar") return state;
|
||||
return {
|
||||
...state,
|
||||
[action.field]: {
|
||||
...field,
|
||||
selectedSource: null,
|
||||
userValue: action.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "TOGGLE_ITEM": {
|
||||
const field = state[action.field];
|
||||
if (field?.kind === "array" || field?.kind === "credits") {
|
||||
return {
|
||||
...state,
|
||||
[action.field]: {
|
||||
...field,
|
||||
items: field.items.map((item) =>
|
||||
item.itemKey === action.itemKey
|
||||
? { ...item, selected: action.selected }
|
||||
: item,
|
||||
),
|
||||
} as FieldState,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case "SET_BASE_SOURCE": {
|
||||
const next = { ...state };
|
||||
for (const [field, fieldState] of Object.entries(next)) {
|
||||
if (fieldState.kind !== "scalar") continue;
|
||||
if (fieldState.candidates.some((c) => c.source === action.source)) {
|
||||
next[field] = {
|
||||
...fieldState,
|
||||
selectedSource: action.source,
|
||||
userValue: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
case "RESET":
|
||||
return action.initial;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Canonical record ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface CanonicalFieldValue {
|
||||
value: unknown;
|
||||
source: SourceKey | "user";
|
||||
}
|
||||
|
||||
export type CanonicalRecord = Partial<Record<string, CanonicalFieldValue>>;
|
||||
|
||||
function deriveCanonicalRecord(state: ReconcilerState): CanonicalRecord {
|
||||
const record: CanonicalRecord = {};
|
||||
|
||||
for (const [field, fieldState] of Object.entries(state)) {
|
||||
if (fieldState.kind === "scalar") {
|
||||
if (fieldState.userValue !== undefined) {
|
||||
record[field] = { value: fieldState.userValue, source: "user" };
|
||||
} else if (fieldState.selectedSource !== null) {
|
||||
const candidate = fieldState.candidates.find(
|
||||
(c) => c.source === fieldState.selectedSource,
|
||||
);
|
||||
if (candidate) {
|
||||
record[field] = { value: candidate.value, source: candidate.source };
|
||||
}
|
||||
}
|
||||
} else if (fieldState.kind === "array") {
|
||||
const selected = fieldState.items.filter((i) => i.selected);
|
||||
if (selected.length > 0) {
|
||||
const counts = selected.reduce<Record<string, number>>((acc, i) => {
|
||||
acc[i.source] = (acc[i.source] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const dominant = Object.entries(counts).sort(
|
||||
([, a], [, b]) => b - a,
|
||||
)[0][0] as SourceKey;
|
||||
record[field] = {
|
||||
value: selected.map((i) => i.rawValue),
|
||||
source: dominant,
|
||||
};
|
||||
}
|
||||
} else if (fieldState.kind === "credits") {
|
||||
const selected = fieldState.items.filter((i) => i.selected);
|
||||
if (selected.length > 0) {
|
||||
record[field] = { value: selected, source: selected[0].source };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseReconcilerResult {
|
||||
state: ReconcilerState;
|
||||
/** Number of scalar fields with a conflict that has no selection yet. */
|
||||
unresolvedCount: number;
|
||||
/** True if any field has candidates from more than one source. */
|
||||
hasConflicts: boolean;
|
||||
canonicalRecord: CanonicalRecord;
|
||||
selectScalar: (field: string, source: SourceKey) => void;
|
||||
/** Override a scalar field with a user-typed value. */
|
||||
setUserValue: (field: string, value: string) => void;
|
||||
toggleItem: (field: string, itemKey: string, selected: boolean) => void;
|
||||
/** Adopt all available fields from a single source. */
|
||||
setBaseSource: (source: SourceKey) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useReconciler(
|
||||
sourcedMetadata: RawSourcedMetadata,
|
||||
inferredMetadata?: RawInferredMetadata,
|
||||
): UseReconcilerResult {
|
||||
const initial = useMemo(() => {
|
||||
const adapters: Partial<Record<SourceKey, AdapterResult>> = {};
|
||||
|
||||
if (!isEmpty(sourcedMetadata.comicvine)) {
|
||||
adapters.comicvine = fromComicVine(
|
||||
sourcedMetadata.comicvine as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
const metron = ensureParsed(sourcedMetadata.metron);
|
||||
if (metron) adapters.metron = fromMetron(metron);
|
||||
|
||||
const gcd = ensureParsed(sourcedMetadata.gcd);
|
||||
if (gcd) adapters.gcd = fromGCD(gcd);
|
||||
|
||||
if (!isEmpty(sourcedMetadata.locg)) {
|
||||
adapters.locg = fromLocg(
|
||||
sourcedMetadata.locg as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
const ci = ensureParsed(sourcedMetadata.comicInfo);
|
||||
if (ci) adapters.comicInfo = fromComicInfo(ci);
|
||||
|
||||
if (inferredMetadata?.issue) {
|
||||
adapters.inferredMetadata = fromInferred(inferredMetadata.issue);
|
||||
}
|
||||
|
||||
return buildState(adapters);
|
||||
}, [sourcedMetadata, inferredMetadata]);
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initial);
|
||||
|
||||
const unresolvedCount = useMemo(
|
||||
() =>
|
||||
Object.values(state).filter(
|
||||
(f) =>
|
||||
f.kind === "scalar" &&
|
||||
f.selectedSource === null &&
|
||||
f.userValue === undefined &&
|
||||
f.candidates.length > 1,
|
||||
).length,
|
||||
[state],
|
||||
);
|
||||
|
||||
const hasConflicts = useMemo(
|
||||
() =>
|
||||
Object.values(state).some(
|
||||
(f) =>
|
||||
(f.kind === "scalar" && f.candidates.length > 1) ||
|
||||
((f.kind === "array" || f.kind === "credits") &&
|
||||
new Set(
|
||||
(f.items as Array<ArrayItem | CreditItem>).map((i) => i.source),
|
||||
).size > 1),
|
||||
),
|
||||
[state],
|
||||
);
|
||||
|
||||
const canonicalRecord = useMemo(() => deriveCanonicalRecord(state), [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
unresolvedCount,
|
||||
hasConflicts,
|
||||
canonicalRecord,
|
||||
selectScalar: (field, source) =>
|
||||
dispatch({ type: "SELECT_SCALAR", field, source }),
|
||||
setUserValue: (field, value) =>
|
||||
dispatch({ type: "SET_USER_VALUE", field, value }),
|
||||
toggleItem: (field, itemKey, selected) =>
|
||||
dispatch({ type: "TOGGLE_ITEM", field, itemKey, selected }),
|
||||
setBaseSource: (source) =>
|
||||
dispatch({ type: "SET_BASE_SOURCE", source }),
|
||||
reset: () => dispatch({ type: "RESET", initial }),
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import prettyBytes from "pretty-bytes";
|
||||
|
||||
export const TorrentDownloads = (props) => {
|
||||
const { data } = props;
|
||||
console.log(Object.values(data));
|
||||
return (
|
||||
<>
|
||||
{data.map(({ torrent }) => {
|
||||
|
||||
@@ -23,15 +23,17 @@ export const TorrentSearchPanel = (props) => {
|
||||
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
|
||||
method: "POST",
|
||||
data: {
|
||||
port: "9696",
|
||||
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
|
||||
offset: 0,
|
||||
categories: [7030],
|
||||
query: searchTerm.issueName,
|
||||
host: "localhost",
|
||||
limit: 100,
|
||||
type: "search",
|
||||
indexerIds: [2],
|
||||
prowlarrQuery: {
|
||||
port: "9696",
|
||||
apiKey: "38c2656e8f5d4790962037b8c4416a8f",
|
||||
offset: 0,
|
||||
categories: [7030],
|
||||
query: searchTerm.issueName,
|
||||
host: "localhost",
|
||||
limit: 100,
|
||||
type: "search",
|
||||
indexerIds: [2],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -41,7 +43,7 @@ export const TorrentSearchPanel = (props) => {
|
||||
mutationFn: async (newTorrent) =>
|
||||
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
||||
onSuccess: async (data) => {
|
||||
console.log(data);
|
||||
// Torrent added successfully
|
||||
},
|
||||
});
|
||||
const searchIndexer = (values) => {
|
||||
|
||||
65
src/client/components/ComicDetail/actionMenuConfig.tsx
Normal file
65
src/client/components/ComicDetail/actionMenuConfig.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface ActionOption {
|
||||
value: string;
|
||||
label: React.ReactElement;
|
||||
}
|
||||
|
||||
export const CVMatchLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Match on ComicVine</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const editLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Edit Metadata</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const deleteLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Delete Comic</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
export const actionOptions: ActionOption[] = [
|
||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||
{ value: "edit-metdata", label: editLabel },
|
||||
{ value: "delete-comic", label: deleteLabel },
|
||||
];
|
||||
|
||||
export const customStyles: StylesConfig<ActionOption, false> = {
|
||||
menu: (base: any) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
}),
|
||||
placeholder: (base: any) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
}),
|
||||
option: (base: any, { isFocused }: any) => ({
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||
}),
|
||||
singleValue: (base: any) => ({
|
||||
...base,
|
||||
paddingTop: "0.4rem",
|
||||
}),
|
||||
control: (base: any) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
color: "black",
|
||||
border: "1px solid rgb(156, 163, 175)",
|
||||
}),
|
||||
};
|
||||
114
src/client/components/ComicDetail/tabConfig.tsx
Normal file
114
src/client/components/ComicDetail/tabConfig.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { lazy } from "react";
|
||||
import { isNil, isEmpty } from "lodash";
|
||||
|
||||
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
|
||||
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
|
||||
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
|
||||
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
|
||||
const DownloadsPanel = lazy(() => import("./DownloadsPanel"));
|
||||
|
||||
interface TabConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: React.ReactElement;
|
||||
content: React.ReactElement | null;
|
||||
shouldShow: boolean;
|
||||
}
|
||||
|
||||
interface TabConfigParams {
|
||||
data: any;
|
||||
hasAnyMetadata: boolean;
|
||||
areRawFileDetailsAvailable: boolean;
|
||||
airDCPPQuery: any;
|
||||
comicObjectId: string;
|
||||
userSettings: any;
|
||||
issueName: string;
|
||||
acquisition?: any;
|
||||
onReconcileMetadata?: () => void;
|
||||
}
|
||||
|
||||
export const createTabConfig = ({
|
||||
data,
|
||||
hasAnyMetadata,
|
||||
areRawFileDetailsAvailable,
|
||||
airDCPPQuery,
|
||||
comicObjectId,
|
||||
userSettings,
|
||||
issueName,
|
||||
acquisition,
|
||||
onReconcileMetadata,
|
||||
}: TabConfigParams): TabConfig[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: "Volume Information",
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||
),
|
||||
content: hasAnyMetadata ? (
|
||||
<VolumeInformation data={data} onReconcile={onReconcileMetadata} />
|
||||
) : null,
|
||||
shouldShow: hasAnyMetadata,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "Archive Operations",
|
||||
content: <ArchiveOperations data={data} />,
|
||||
shouldShow: areRawFileDetailsAvailable,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "DC++ Search",
|
||||
content: (
|
||||
<AcquisitionPanel
|
||||
query={airDCPPQuery}
|
||||
comicObjectId={comicObjectId}
|
||||
comicObject={data}
|
||||
settings={userSettings}
|
||||
/>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: (
|
||||
<span className="inline-flex flex-row">
|
||||
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
</span>
|
||||
),
|
||||
name: "Torrent Search",
|
||||
content: <TorrentSearchPanel comicObjectId={comicObjectId} issueName={issueName} />,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Downloads",
|
||||
icon: (
|
||||
<>
|
||||
{(acquisition?.directconnect?.downloads?.length || 0) +
|
||||
(acquisition?.torrent?.length || 0)}
|
||||
</>
|
||||
),
|
||||
content:
|
||||
!isNil(data) && !isEmpty(data) ? (
|
||||
<DownloadsPanel />
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
89
src/client/components/ComicDetail/useComicVineMatching.ts
Normal file
89
src/client/components/ComicDetail/useComicVineMatching.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import axios from "axios";
|
||||
import { isNil, isUndefined, isEmpty } from "lodash";
|
||||
import { refineQuery } from "filename-parser";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||
|
||||
type ComicVineMatch = {
|
||||
score: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type ComicVineSearchQuery = {
|
||||
inferredIssueDetails: {
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type ComicVineMetadata = {
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export const useComicVineMatching = () => {
|
||||
const [comicVineMatches, setComicVineMatches] = useState<ComicVineMatch[]>([]);
|
||||
|
||||
const fetchComicVineMatches = async (
|
||||
searchPayload: any,
|
||||
issueSearchQuery: ComicVineSearchQuery,
|
||||
seriesSearchQuery: ComicVineSearchQuery,
|
||||
) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
// hack
|
||||
query: issueSearchQuery.inferredIssueDetails.name
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim(),
|
||||
limit: "100",
|
||||
page: 1,
|
||||
resources: "volume",
|
||||
scorerConfiguration: {
|
||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||
},
|
||||
rawFileDetails: searchPayload,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
},
|
||||
});
|
||||
let matches: ComicVineMatch[] = [];
|
||||
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||
matches = response.data.results;
|
||||
} else {
|
||||
matches = response.data.map((match: ComicVineMatch) => match);
|
||||
}
|
||||
const scoredMatches = matches.sort((a: ComicVineMatch, b: ComicVineMatch) => b.score - a.score);
|
||||
setComicVineMatches(scoredMatches);
|
||||
} catch (err) {
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
|
||||
const prepareAndFetchMatches = (
|
||||
rawFileDetails: RawFileDetailsType | undefined,
|
||||
comicvine: ComicVineMetadata | undefined,
|
||||
) => {
|
||||
let seriesSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
||||
let issueSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
||||
|
||||
if (!isUndefined(rawFileDetails) && rawFileDetails.name) {
|
||||
issueSearchQuery = refineQuery(rawFileDetails.name) as ComicVineSearchQuery;
|
||||
} else if (!isEmpty(comicvine) && comicvine?.name) {
|
||||
issueSearchQuery = refineQuery(comicvine.name) as ComicVineSearchQuery;
|
||||
}
|
||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||
};
|
||||
|
||||
return {
|
||||
comicVineMatches,
|
||||
prepareAndFetchMatches,
|
||||
};
|
||||
};
|
||||
@@ -1,78 +1,72 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import ZeroState from "./ZeroState";
|
||||
import { RecentlyImported } from "./RecentlyImported";
|
||||
import { WantedComicsList } from "./WantedComicsList";
|
||||
import { VolumeGroups } from "./VolumeGroups";
|
||||
import { LibraryStatistics } from "./LibraryStatistics";
|
||||
import { PullList } from "./PullList";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import {
|
||||
useGetRecentComicsQuery,
|
||||
useGetWantedComicsQuery,
|
||||
useGetVolumeGroupsQuery,
|
||||
useGetLibraryStatisticsQuery
|
||||
} from "../../graphql/generated";
|
||||
|
||||
export const Dashboard = (): ReactElement => {
|
||||
const { data: recentComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": false },
|
||||
comicStatus: "recent",
|
||||
},
|
||||
}),
|
||||
queryKey: ["recentComics"],
|
||||
});
|
||||
// Use GraphQL for recent comics
|
||||
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
|
||||
{ limit: 5 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const { data: wantedComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": true },
|
||||
},
|
||||
}),
|
||||
queryKey: ["wantedComics"],
|
||||
});
|
||||
const { data: volumeGroups } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["volumeGroups"],
|
||||
});
|
||||
// Wanted Comics - using GraphQL
|
||||
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
sort: '{"updatedAt": -1}'
|
||||
},
|
||||
predicate: '{"acquisition.source.wanted": true}'
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
}
|
||||
);
|
||||
|
||||
const { data: statistics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["libraryStatistics"],
|
||||
});
|
||||
// Volume Groups - using GraphQL
|
||||
const { data: volumeGroupsData, error: volumeGroupsError } = useGetVolumeGroupsQuery(
|
||||
undefined,
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
// Library Statistics - using GraphQL
|
||||
const { data: statisticsData, error: statisticsError } = useGetLibraryStatisticsQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
}
|
||||
);
|
||||
|
||||
const recentComics = recentComicsData?.comics?.comics || [];
|
||||
const wantedComics = !wantedComicsError ? (wantedComicsData?.getComicBooks?.docs || []) : [];
|
||||
const volumeGroups = volumeGroupsData?.getComicBookGroups || [];
|
||||
const statistics = !statisticsError ? statisticsData?.getLibraryStatistics : undefined;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-full">
|
||||
<PullList />
|
||||
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||
{/* Wanted comics */}
|
||||
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||
{/* Library Statistics */}
|
||||
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||
{/* Volume groups */}
|
||||
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||
</div>
|
||||
<>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<PullList />
|
||||
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
|
||||
{/* Wanted comics */}
|
||||
<WantedComicsList comics={wantedComics} />
|
||||
{/* Library Statistics */}
|
||||
{statistics && <LibraryStatistics stats={statistics} />}
|
||||
{/* Volume groups */}
|
||||
<VolumeGroups volumeGroups={volumeGroups} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,100 +1,106 @@
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty, isUndefined, map } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
import Header from "../shared/Header";
|
||||
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
||||
|
||||
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
||||
comicDirectorySize: DirectorySize;
|
||||
comicsMissingFiles: number;
|
||||
};
|
||||
|
||||
/** Props for {@link LibraryStatistics}. */
|
||||
interface LibraryStatisticsProps {
|
||||
stats: Stats | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a snapshot of library metrics: total comic files, tagging coverage,
|
||||
* file-type breakdown, and the publisher with the most issues.
|
||||
*
|
||||
* Returns `null` when `stats` is absent or the statistics array is empty.
|
||||
*/
|
||||
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
|
||||
if (!stats || !stats.totalDocuments) return null;
|
||||
|
||||
const facet = stats.statistics?.[0];
|
||||
if (!facet) return null;
|
||||
|
||||
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
|
||||
const topPublisher = publisherWithMostComicsInLibrary?.[0];
|
||||
|
||||
export const LibraryStatistics = (
|
||||
props: ILibraryStatisticsProps,
|
||||
): ReactElement => {
|
||||
const { stats } = props;
|
||||
return (
|
||||
<div className="mt-5">
|
||||
{/* TODO: Switch iconClassNames to Solar icon */}
|
||||
<Header
|
||||
headerContent="Your Library In Numbers"
|
||||
subHeaderContent={
|
||||
<span className="text-md">A brief snapshot of your library.</span>
|
||||
}
|
||||
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-row gap-5">
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{props.stats.totalDocuments} files
|
||||
</dd>
|
||||
<dd>
|
||||
<span className="text-2xl text-green-600">
|
||||
{props.stats.comicDirectorySize &&
|
||||
prettyBytes(props.stats.comicDirectorySize)}
|
||||
</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}
|
||||
</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}
|
||||
</span>{" "}
|
||||
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||
with ComicInfo.xml
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
|
||||
>
|
||||
{fileType.data.length} {fileType._id}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file types */}
|
||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||
{/* publisher with most issues */}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(
|
||||
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||
) && (
|
||||
<>
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0]._id
|
||||
}
|
||||
</span>
|
||||
{" has the most issues "}
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0].count
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row gap-5">
|
||||
{/* Total records in database */}
|
||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">In database</dt>
|
||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||
{stats.totalDocuments} comics
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Missing files */}
|
||||
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
|
||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||
{stats.comicsMissingFiles}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Disk space consumed */}
|
||||
{stats.comicDirectorySize.totalSizeInGB != null && (
|
||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
|
||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tagging coverage */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{issues && issues.length > 0 && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||
<span className="text-xl text-gray-700">{issues.length}</span>
|
||||
tagged with ComicVine
|
||||
</div>
|
||||
)}
|
||||
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
|
||||
with ComicInfo.xml
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File-type breakdown */}
|
||||
{fileTypes && fileTypes.length > 0 && (
|
||||
<div>
|
||||
{fileTypes.map((ft) => (
|
||||
<span
|
||||
key={ft.id}
|
||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
|
||||
>
|
||||
{ft.data.length} {ft.id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publisher with most issues */}
|
||||
{topPublisher && (
|
||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
|
||||
<span>{topPublisher.id}</span>
|
||||
{" has the most issues "}
|
||||
<span>{topPublisher.count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,73 +2,86 @@ import React, { ReactElement, useState } from "react";
|
||||
import { map } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import { importToDB } from "../../actions/fileops.actions";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import "keen-slider/keen-slider.min.css";
|
||||
import { useKeenSlider } from "keen-slider/react";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import { Form } from "react-final-form";
|
||||
import DatePickerDialog from "../shared/DatePicker";
|
||||
import { format } from "date-fns";
|
||||
import { LocgMetadata, useGetWeeklyPullListQuery } from "../../graphql/generated";
|
||||
|
||||
type PullListProps = {
|
||||
issues: any;
|
||||
};
|
||||
interface PullListProps {
|
||||
issues?: LocgMetadata[];
|
||||
}
|
||||
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
maxRPS: 1,
|
||||
});
|
||||
const cachedAxios = setupCache(axios);
|
||||
export const PullList = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// datepicker
|
||||
const date = new Date();
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
format(date, "M-dd-yyyy"),
|
||||
format(date, "yyyy/M/dd"),
|
||||
);
|
||||
|
||||
// keen slider
|
||||
const [sliderRef, instanceRef] = useKeenSlider(
|
||||
{
|
||||
loop: true,
|
||||
slides: {
|
||||
origin: "auto",
|
||||
number: 15,
|
||||
perView: 5,
|
||||
spacing: 15,
|
||||
},
|
||||
slideChanged() {
|
||||
console.log("slide changed");
|
||||
},
|
||||
},
|
||||
[
|
||||
// add plugins here
|
||||
],
|
||||
);
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
const {
|
||||
data: pullList,
|
||||
data: pullListData,
|
||||
refetch,
|
||||
isSuccess,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryFn: async (): any =>
|
||||
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||
method: "get",
|
||||
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
|
||||
}),
|
||||
queryKey: ["pullList", inputValue],
|
||||
} = useGetWeeklyPullListQuery({
|
||||
input: {
|
||||
startDate: inputValue,
|
||||
pageSize: 15,
|
||||
currentPage: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Transform the data to match the old structure
|
||||
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined;
|
||||
|
||||
const { mutate: addToLibrary } = useMutation({
|
||||
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => {
|
||||
const comicBookMetadata = {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata: metadata || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
},
|
||||
};
|
||||
|
||||
return await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||
method: "POST",
|
||||
data: comicBookMetadata,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch wanted comics queries
|
||||
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||
},
|
||||
});
|
||||
const addToLibrary = (sourceName: string, locgMetadata) =>
|
||||
importToDB(sourceName, { locg: locgMetadata });
|
||||
|
||||
const next = () => {
|
||||
// sliderRef.slickNext();
|
||||
@@ -79,14 +92,14 @@ export const PullList = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
<Header
|
||||
{/* TODO: Switch iconClassNames to Solar icon */}
|
||||
<Header
|
||||
headerContent="Discover"
|
||||
subHeaderContent={
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://leagueofcomicgeeks.com/comics/new-comics">
|
||||
<a href="https://leagueofcomicgeeks.com">
|
||||
League Of Comic Geeks
|
||||
</a>
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
@@ -123,43 +136,46 @@ export const PullList = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuccess && !isLoading && (
|
||||
<div ref={sliderRef} className="keen-slider flex flex-row">
|
||||
{map(pullList?.data.result, (issue, idx) => {
|
||||
return (
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.cover}
|
||||
hasDetails
|
||||
title={ellipsize(issue.name, 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
{issue.publisher}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<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={() => addToLibrary("locg", issue)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-lvw -mr-4 sm:-mr-6 lg:-mr-8">
|
||||
{isSuccess && !isLoading && (
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{map(pullList?.data.result, (issue: LocgMetadata, idx: number) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.cover || undefined}
|
||||
hasDetails
|
||||
title={ellipsize(issue.name || 'Unknown', 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
{issue.publisher || 'Unknown Publisher'}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<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={() => addToLibrary({ sourceName: "locg", metadata: { locg: issue } })}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? <div>Loading...</div> : null}
|
||||
{isError ? (
|
||||
<div>An error occurred while retrieving the pull list.</div>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{isError && <div>An error occurred while retrieving the pull list.</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,64 +4,83 @@ import { Link } from "react-router-dom";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import {
|
||||
determineCoverFile,
|
||||
determineExternalMetadata,
|
||||
} from "../../shared/utils/metadata.utils";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { GetRecentComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type RecentlyImportedProps = {
|
||||
comics: any;
|
||||
comics: GetRecentComicsQuery['comics']['comics'];
|
||||
};
|
||||
|
||||
export const RecentlyImported = (
|
||||
comics: RecentlyImportedProps,
|
||||
{ comics }: RecentlyImportedProps,
|
||||
): ReactElement => {
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* TODO: Switch iconClassNames to Solar icon */}
|
||||
<Header
|
||||
headerContent="Recently Imported"
|
||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{comics?.comics.map(
|
||||
(
|
||||
{
|
||||
_id,
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{comics?.map((comic, idx) => {
|
||||
const {
|
||||
id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
sourcedMetadata,
|
||||
canonicalMetadata,
|
||||
inferredMetadata,
|
||||
acquisition: {
|
||||
source: { name },
|
||||
},
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
importStatus,
|
||||
} = comic;
|
||||
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicvine)
|
||||
: sourcedMetadata?.comicvine;
|
||||
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicInfo)
|
||||
: sourcedMetadata?.comicInfo;
|
||||
const locg = sourcedMetadata?.locg;
|
||||
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const isComicVineMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
|
||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||
const isMissingFile = importStatus?.isRawFileMissing === true;
|
||||
const cardState = isMissingFile
|
||||
? "missing"
|
||||
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||
return (
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
<div
|
||||
key={idx}
|
||||
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||
title={inferredMetadata.issue.name}
|
||||
hasDetails
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
imageUrl={url}
|
||||
title={inferredMetadata?.issue?.name}
|
||||
hasDetails
|
||||
cardState={cardState}
|
||||
>
|
||||
<div>
|
||||
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||
{/* Issue number */}
|
||||
@@ -70,7 +89,7 @@ export const RecentlyImported = (
|
||||
<i className="icon-[solar--hashtag-outline]"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
{inferredMetadata?.issue?.number}
|
||||
</span>
|
||||
</span>
|
||||
{/* File extension */}
|
||||
@@ -80,7 +99,7 @@ export const RecentlyImported = (
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.extension}
|
||||
{rawFileDetails?.extension}
|
||||
</span>
|
||||
</span>
|
||||
{/* Uncompressed status */}
|
||||
@@ -98,31 +117,28 @@ export const RecentlyImported = (
|
||||
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
||||
{/* ComicInfo.xml presence */}
|
||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||
<div mt-1>
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||
<div className="mt-1">
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-gray-500 dark:text-white-300"></i>
|
||||
</div>
|
||||
)}
|
||||
{/* ComicVine metadata presence */}
|
||||
{isComicVineMetadataAvailable && (
|
||||
<span className="w-7 h-7">
|
||||
<span className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Raw file presence */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,59 +4,82 @@ import ellipsize from "ellipsize";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { GetVolumeGroupsQuery } from "../../graphql/generated";
|
||||
|
||||
export const VolumeGroups = (props): ReactElement => {
|
||||
type VolumeGroupsProps = {
|
||||
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
||||
};
|
||||
|
||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
|
||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateToVolumes = (row) => {
|
||||
const navigateToVolumes = (row: any) => {
|
||||
navigate(`/volumes/all`);
|
||||
};
|
||||
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
{/* TODO: Switch iconClassNames to Solar icon */}
|
||||
<Header
|
||||
headerContent="Volumes"
|
||||
subHeaderContent="Based on ComicVine Volume information"
|
||||
subHeaderContent={<>Based on ComicVine Volume information</>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/volumes"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(deduplicatedGroups, (data) => {
|
||||
return (
|
||||
<div className="max-w-sm mx-auto" key={data._id}>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
key={data._id}
|
||||
imageUrl={data.volumes.image.small_url}
|
||||
hasDetails
|
||||
>
|
||||
<div className="py-3">
|
||||
<div className="text-sm">
|
||||
<Link to={`/volume/details/${data._id}`}>
|
||||
{ellipsize(data.volumes.name, 48)}
|
||||
</Link>
|
||||
</div>
|
||||
{/* issue count */}
|
||||
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{map(deduplicatedGroups, (data) => {
|
||||
return (
|
||||
<div
|
||||
key={data.id}
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
imageUrl={data.volumes?.image?.small_url || undefined}
|
||||
hasDetails
|
||||
>
|
||||
<div className="py-3">
|
||||
<div className="text-sm">
|
||||
<Link to={`/volume/details/${data.id}`}>
|
||||
{ellipsize(data.volumes?.name || 'Unknown', 48)}
|
||||
</Link>
|
||||
</div>
|
||||
{/* issue count */}
|
||||
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{data.volumes.count_of_issues} issues
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{data.volumes?.count_of_issues || 0} issues
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
||||
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
||||
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
||||
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
||||
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,101 +6,134 @@ import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { GetWantedComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type WantedComicsListProps = {
|
||||
comics: any;
|
||||
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
|
||||
};
|
||||
|
||||
export const WantedComicsList = ({
|
||||
comics,
|
||||
}: WantedComicsListProps): ReactElement => {
|
||||
}: WantedComicsListProps): ReactElement | null => {
|
||||
if (!comics || comics.length === 0) return null;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{/* TODO: Switch iconClassNames to Solar icon */}
|
||||
<Header
|
||||
headerContent="Wanted Comics"
|
||||
subHeaderContent="Comics marked as wanted from various sources"
|
||||
subHeaderContent={<>Comics marked as wanted from various sources</>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/wanted"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(
|
||||
comics,
|
||||
({
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
}) => {
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{map(
|
||||
comics,
|
||||
(comic) => {
|
||||
const {
|
||||
id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata,
|
||||
} = comic;
|
||||
|
||||
const { issueName, url } = determineCoverFile(
|
||||
consolidatedComicMetadata,
|
||||
);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={_id}
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="pb-1">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(
|
||||
detectIssueTypes(comicvine.volumeInformation.description),
|
||||
) ? (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicvine)
|
||||
: sourcedMetadata?.comicvine;
|
||||
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicInfo)
|
||||
: sourcedMetadata?.comicInfo;
|
||||
const locg = sourcedMetadata?.locg;
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(
|
||||
comicvine.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(locg) && (
|
||||
<img
|
||||
src="/src/client/assets/img/locglogo.svg"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
cardState="wanted"
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine?.description)) ? (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(comicvine?.description)
|
||||
?.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Wanted comics - info not available in current GraphQL query */}
|
||||
</div>
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(locg) && (
|
||||
<img
|
||||
src="/src/client/assets/img/locglogo.svg"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,13 +57,12 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||
}, [issueBundles]);
|
||||
|
||||
return !isNil(bundles) ? (
|
||||
<div className="container">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<section className="section">
|
||||
<h1 className="title">Downloads</h1>
|
||||
<div className="columns">
|
||||
<div className="column is-half">
|
||||
{bundles.map((bundle, idx) => {
|
||||
console.log(bundle);
|
||||
return (
|
||||
<div key={idx}>
|
||||
<MetadataPanel
|
||||
|
||||
@@ -47,6 +47,7 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
||||
onChange={(e) => performSearch(e)}
|
||||
/>
|
||||
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span className="icon is-right mt-2">
|
||||
<i className="fa-solid fa-magnifying-glass"></i>
|
||||
</span>
|
||||
|
||||
493
src/client/components/Import/Import.test.tsx
Normal file
493
src/client/components/Import/Import.test.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Import } from './Import';
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.MockedFunction<any>;
|
||||
|
||||
// Mock zustand store
|
||||
const mockGetSocket = jest.fn();
|
||||
const mockDisconnectSocket = jest.fn();
|
||||
const mockSetStatus = jest.fn();
|
||||
|
||||
jest.mock('../../store', () => ({
|
||||
useStore: jest.fn((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'drained',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock socket.io-client
|
||||
const mockSocket = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
};
|
||||
|
||||
mockGetSocket.mockReturnValue(mockSocket);
|
||||
|
||||
// Helper function to create a wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Import Component - Numerical Indices', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should display numerical indices in the Past Imports table', async () => {
|
||||
// Mock API response with 3 import sessions
|
||||
const mockData = [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
earliestTimestamp: '2024-01-01T10:00:00Z',
|
||||
completedJobs: 5,
|
||||
failedJobs: 0
|
||||
},
|
||||
{
|
||||
sessionId: 'session-2',
|
||||
earliestTimestamp: '2024-01-02T10:00:00Z',
|
||||
completedJobs: 3,
|
||||
failedJobs: 1
|
||||
},
|
||||
{
|
||||
sessionId: 'session-3',
|
||||
earliestTimestamp: '2024-01-03T10:00:00Z',
|
||||
completedJobs: 8,
|
||||
failedJobs: 2
|
||||
},
|
||||
];
|
||||
|
||||
(axios as any).mockResolvedValue({ data: mockData });
|
||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Wait for the "Past Imports" heading to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify that the "#" column header exists
|
||||
expect(screen.getByText('#')).toBeInTheDocument();
|
||||
|
||||
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
|
||||
const rows = screen.getAllByRole('row');
|
||||
// Skip header row (index 0), check data rows
|
||||
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
|
||||
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
|
||||
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
test('should display correct indices for larger datasets', async () => {
|
||||
// Mock API response with 10 import sessions
|
||||
const mockData = Array.from({ length: 10 }, (_, i) => ({
|
||||
sessionId: `session-${i + 1}`,
|
||||
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
completedJobs: i + 1,
|
||||
failedJobs: 0,
|
||||
}));
|
||||
|
||||
(axios as any).mockResolvedValue({ data: mockData });
|
||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify indices 1 through 10 are present in the first column
|
||||
const rows = screen.getAllByRole('row');
|
||||
// Skip header row (index 0)
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const row = rows[i];
|
||||
const cells = row.querySelectorAll('td');
|
||||
expect(cells[0]).toHaveTextContent(i.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import Component - Button Visibility', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(axios as any).mockResolvedValue({ data: [] });
|
||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
test('should show Start Import button when queue status is drained', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'drained',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify Pause and Resume buttons are NOT visible
|
||||
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show Start Import button when queue status is undefined', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: undefined,
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide Start Import button and show Pause button when queue is running', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'running',
|
||||
successfulJobCount: 5,
|
||||
failedJobCount: 1,
|
||||
mostRecentImport: 'Comic #123',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Pause')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify Import Activity section is visible
|
||||
expect(screen.getByText('Import Activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
|
||||
});
|
||||
|
||||
test('should hide Start Import button and show Resume button when queue is paused', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'paused',
|
||||
successfulJobCount: 3,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: 'Comic #456',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Resume')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import Component - SessionId and Socket Reconnection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
(axios as any).mockResolvedValue({ data: [] });
|
||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
|
||||
// Setup: Set old sessionId in localStorage
|
||||
localStorage.setItem('sessionId', 'old-session-id');
|
||||
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'drained',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Click the "Start Import" button
|
||||
const startButton = await screen.findByText('Start Import');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
// Verify sessionId is cleared immediately
|
||||
expect(localStorage.getItem('sessionId')).toBeNull();
|
||||
|
||||
// Verify disconnectSocket is called
|
||||
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
|
||||
|
||||
// Fast-forward 100ms
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// Verify getSocket is called after 100ms
|
||||
await waitFor(() => {
|
||||
expect(mockGetSocket).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
// Fast-forward another 500ms
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify initiateImport is called and status is set to running
|
||||
await waitFor(() => {
|
||||
expect(axios.request).toHaveBeenCalledWith({
|
||||
url: 'http://localhost:3000/api/library/newImport',
|
||||
method: 'POST',
|
||||
data: { sessionId: null },
|
||||
});
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
||||
});
|
||||
});
|
||||
|
||||
test('should NOT clear sessionId when starting import with undefined status', async () => {
|
||||
// Setup: Set existing sessionId in localStorage
|
||||
localStorage.setItem('sessionId', 'existing-session-id');
|
||||
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: undefined,
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Click the "Start Import" button
|
||||
const startButton = await screen.findByText('Start Import');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
// Verify sessionId is NOT cleared
|
||||
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
|
||||
|
||||
// Verify disconnectSocket is NOT called
|
||||
expect(mockDisconnectSocket).not.toHaveBeenCalled();
|
||||
|
||||
// Verify status is set to running immediately
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
||||
|
||||
// Verify initiateImport is called immediately (no delay)
|
||||
await waitFor(() => {
|
||||
expect(axios.request).toHaveBeenCalledWith({
|
||||
url: 'http://localhost:3000/api/library/newImport',
|
||||
method: 'POST',
|
||||
data: { sessionId: 'existing-session-id' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import Component - Real-time Updates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(axios as any).mockResolvedValue({ data: [] });
|
||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'running',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Wait for component to mount and socket listeners to be attached
|
||||
await waitFor(() => {
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
||||
});
|
||||
|
||||
// Get the event handler that was registered
|
||||
const coverExtractedHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'LS_COVER_EXTRACTED'
|
||||
)?.[1];
|
||||
|
||||
// Clear previous axios calls
|
||||
(axios as any).mockClear();
|
||||
|
||||
// Simulate the socket event
|
||||
if (coverExtractedHandler) {
|
||||
coverExtractedHandler();
|
||||
}
|
||||
|
||||
// Verify that the API is called again (refetch)
|
||||
await waitFor(() => {
|
||||
expect(axios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'running',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Wait for component to mount and socket listeners to be attached
|
||||
await waitFor(() => {
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
||||
});
|
||||
|
||||
// Get the event handler that was registered
|
||||
const queueDrainedHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
|
||||
)?.[1];
|
||||
|
||||
// Clear previous axios calls
|
||||
(axios as any).mockClear();
|
||||
|
||||
// Simulate the socket event
|
||||
if (queueDrainedHandler) {
|
||||
queueDrainedHandler();
|
||||
}
|
||||
|
||||
// Verify that the API is called again (refetch)
|
||||
await waitFor(() => {
|
||||
expect(axios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should cleanup socket listeners on unmount', async () => {
|
||||
const { useStore } = require('../../store');
|
||||
useStore.mockImplementation((selector: any) =>
|
||||
selector({
|
||||
importJobQueue: {
|
||||
status: 'drained',
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
mostRecentImport: '',
|
||||
setStatus: mockSetStatus,
|
||||
},
|
||||
getSocket: mockGetSocket,
|
||||
disconnectSocket: mockDisconnectSocket,
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||
|
||||
// Wait for socket listeners to be attached
|
||||
await waitFor(() => {
|
||||
expect(mockSocket.on).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Verify that socket listeners are removed
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -1,125 +1,136 @@
|
||||
import React, { ReactElement, useCallback, useEffect } from "react";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import Loader from "react-loader-spinner";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
interface IProps {
|
||||
matches?: unknown;
|
||||
fetchComicMetadata?: any;
|
||||
path: string;
|
||||
covers?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to facilitate the import of comics to the ThreeTwo library
|
||||
*
|
||||
* @param x - The first input number
|
||||
* @param y - The second input number
|
||||
* @returns The arithmetic mean of `x` and `y`
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
|
||||
export const Import = (props: IProps): ReactElement => {
|
||||
export const Import = (): ReactElement => {
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, socketIOInstance } = useStore(
|
||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
importJobQueue: state.importJobQueue,
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
getSocket: state.getSocket,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
})),
|
||||
);
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
const { mutate: initiateImport } = useMutation({
|
||||
mutationFn: async () =>
|
||||
await axios.request({
|
||||
url: `http://localhost:3000/api/library/newImport`,
|
||||
// Force re-import mutation - re-imports all files regardless of import status
|
||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
return await axios.request({
|
||||
url: `http://localhost:3000/api/library/forceReImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
console.log("Force re-import initiated:", response.data);
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Failed to start force re-import:", error);
|
||||
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError, isLoading } = useQuery({
|
||||
queryKey: ["allImportJobResults"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
method: "GET",
|
||||
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
|
||||
}),
|
||||
});
|
||||
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||
|
||||
const importSession = useImportSessionStatus();
|
||||
const hasActiveSession = importSession.isActive;
|
||||
const wasComplete = useRef(false);
|
||||
|
||||
// React to importSession.isComplete rather than socket events — more reliable
|
||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
||||
useEffect(() => {
|
||||
if (importSession.isComplete && !wasComplete.current) {
|
||||
wasComplete.current = true;
|
||||
// Small delay so the backend has time to commit job result stats
|
||||
setTimeout(() => {
|
||||
// Invalidate the cache to force a fresh fetch of job result statistics
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
refetch();
|
||||
}, 1500);
|
||||
importJobQueue.setStatus("drained");
|
||||
} else if (!importSession.isComplete) {
|
||||
wasComplete.current = false;
|
||||
}
|
||||
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
||||
|
||||
// Listen to socket events to update Past Imports table in real-time
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleImportCompleted = () => {
|
||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
};
|
||||
}, [getSocket, queryClient]);
|
||||
|
||||
const toggleQueue = (queueAction: string, queueStatus: string) => {
|
||||
socketIOInstance.emit(
|
||||
"call",
|
||||
"socket.setQueueStatus",
|
||||
{
|
||||
queueAction,
|
||||
queueStatus,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Method to render import job queue pause/resume controls on the UI
|
||||
*
|
||||
* @param status The `string` status (either `"pause"` or `"resume"`)
|
||||
* @returns ReactElement A `<button/>` that toggles queue status
|
||||
* @remarks Sets the global `importJobQueue.status` state upon toggling
|
||||
* Handles force re-import - re-imports all files to fix indexing issues
|
||||
*/
|
||||
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>
|
||||
);
|
||||
const handleForceReImport = async () => {
|
||||
setImportError(null);
|
||||
|
||||
case "drained":
|
||||
return 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;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
if (window.confirm(
|
||||
"This will re-import ALL files in your library folder, even those already imported. " +
|
||||
"This can help fix Elasticsearch indexing issues. Continue?"
|
||||
)) {
|
||||
if (importJobQueue.status === "drained") {
|
||||
localStorage.removeItem("sessionId");
|
||||
disconnectSocket("/");
|
||||
setTimeout(() => {
|
||||
getSocket("/");
|
||||
setTimeout(() => {
|
||||
forceReImport();
|
||||
}, 500);
|
||||
}, 100);
|
||||
} else {
|
||||
forceReImport();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
@@ -154,70 +165,61 @@ export const Import = (props: IProps): ReactElement => {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="my-4">
|
||||
{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-indigo-500"
|
||||
onClick={() => {
|
||||
initiateImport();
|
||||
importJobQueue.setStatus("running");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Start Import</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Import Statistics */}
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<RealTimeImportStats />
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
{(importJobQueue.status === "running" ||
|
||||
importJobQueue.status === "paused") && (
|
||||
<>
|
||||
<span className="flex items-center my-5 max-w-screen-lg">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Import Activity
|
||||
{/* Error Message */}
|
||||
{importError && (
|
||||
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
|
||||
{/* Successful import counts */}
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{importJobQueue.successfulJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
imported
|
||||
</dt>
|
||||
</div>
|
||||
{/* Failed job counts */}
|
||||
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||
{importJobQueue.failedJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
failed
|
||||
</dt>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col dark:text-slate-200 text-slate-400">
|
||||
<dd>{renderQueueControls(importJobQueue.status)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
||||
Import Error
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||
{importError}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportError(null)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||
>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="mt-2 dark:text-slate-200 text-slate-400">
|
||||
Imported: <span>{importJobQueue.mostRecentImport}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past imports */}
|
||||
{!isLoading && !isEmpty(data?.data) && (
|
||||
{/* Force Re-Import Button - always shown when no import is running */}
|
||||
{!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleForceReImport}
|
||||
disabled={isForceReImporting || hasActiveSession}
|
||||
title="Re-import all files to fix Elasticsearch indexing issues"
|
||||
>
|
||||
<span className="text-md font-medium">
|
||||
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
||||
</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||
|
||||
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
||||
<div className="max-w-screen-lg">
|
||||
<span className="flex items-center mt-6">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
@@ -230,6 +232,9 @@ export const Import = (props: IProps): ReactElement => {
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<thead className="ltr:text-left rtl:text-right">
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
#
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Time Started
|
||||
</th>
|
||||
@@ -246,14 +251,19 @@ export const Import = (props: IProps): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.data.map((jobResult, id) => {
|
||||
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
|
||||
return (
|
||||
<tr key={id}>
|
||||
<tr key={index}>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{format(
|
||||
new Date(jobResult.earliestTimestamp),
|
||||
"EEEE, hh:mma, do LLLL Y",
|
||||
)}
|
||||
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
|
||||
? format(
|
||||
new Date(parseInt(jobResult.earliestTimestamp)),
|
||||
"EEEE, hh:mma, do LLLL y",
|
||||
)
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">
|
||||
|
||||
379
src/client/components/Import/RealTimeImportStats.tsx
Normal file
379
src/client/components/Import/RealTimeImportStats.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useGetImportStatisticsQuery,
|
||||
useGetWantedComicsQuery,
|
||||
useStartIncrementalImportMutation,
|
||||
} from "../../graphql/generated";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
/**
|
||||
* Import statistics with card-based layout and progress bar.
|
||||
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
||||
* Also surfaces missing files detected by the file watcher.
|
||||
*/
|
||||
export const RealTimeImportStats = (): ReactElement => {
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
||||
const [socketImport, setSocketImport] = useState<{
|
||||
active: boolean;
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||
useShallow((state) => ({
|
||||
getSocket: state.getSocket,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
importJobQueue: state.importJobQueue,
|
||||
})),
|
||||
);
|
||||
|
||||
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
||||
{},
|
||||
{ refetchOnWindowFocus: false, refetchInterval: false },
|
||||
);
|
||||
|
||||
const stats = importStats?.getImportStatistics?.stats;
|
||||
|
||||
// File list for the detail panel — only fetched when there are missing files
|
||||
const { data: missingComicsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 3, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false,
|
||||
enabled: (stats?.missingFiles ?? 0) > 0,
|
||||
},
|
||||
);
|
||||
|
||||
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
||||
|
||||
const getMissingComicLabel = (comic: any): string => {
|
||||
const series =
|
||||
comic.canonicalMetadata?.series?.value ??
|
||||
comic.inferredMetadata?.issue?.name;
|
||||
const issueNum =
|
||||
comic.canonicalMetadata?.issueNumber?.value ??
|
||||
comic.inferredMetadata?.issue?.number;
|
||||
if (series && issueNum) return `${series} #${issueNum}`;
|
||||
if (series) return series;
|
||||
return comic.rawFileDetails?.name ?? comic.id;
|
||||
};
|
||||
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
||||
useStartIncrementalImportMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.startIncrementalImport.success) {
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setImportError(
|
||||
error?.message || "Failed to start import. Please try again.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const hasNewFiles = stats && stats.newFiles > 0;
|
||||
const missingCount = stats?.missingFiles ?? 0;
|
||||
|
||||
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
||||
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleStatsChange = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
||||
};
|
||||
|
||||
const handleFileDetected = (payload: { filePath: string }) => {
|
||||
handleStatsChange();
|
||||
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
||||
setDetectedFile(name);
|
||||
setTimeout(() => setDetectedFile(null), 5000);
|
||||
};
|
||||
|
||||
const handleImportStarted = () => {
|
||||
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
||||
};
|
||||
|
||||
const handleCoverExtracted = (payload: {
|
||||
completedJobCount: number;
|
||||
totalJobCount: number;
|
||||
importResult: unknown;
|
||||
}) => {
|
||||
setSocketImport((prev) => ({
|
||||
active: true,
|
||||
completed: payload.completedJobCount,
|
||||
total: payload.totalJobCount,
|
||||
failed: prev?.failed ?? 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCoverExtractionFailed = (payload: {
|
||||
failedJobCount: number;
|
||||
importResult: unknown;
|
||||
}) => {
|
||||
setSocketImport((prev) =>
|
||||
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
||||
handleStatsChange();
|
||||
};
|
||||
|
||||
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
||||
socket.on("LS_FILES_MISSING", handleStatsChange);
|
||||
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
|
||||
return () => {
|
||||
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
||||
socket.off("LS_FILES_MISSING", handleStatsChange);
|
||||
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
};
|
||||
}, [getSocket, queryClient]);
|
||||
|
||||
const handleStartImport = async () => {
|
||||
setImportError(null);
|
||||
|
||||
if (importSession.isActive) {
|
||||
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("/");
|
||||
setTimeout(() => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
startIncrementalImport({ sessionId });
|
||||
}, 500);
|
||||
}, 100);
|
||||
} else {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
startIncrementalImport({ sessionId });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !stats) {
|
||||
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
const isFirstImport = stats.alreadyImported === 0;
|
||||
const buttonText = isFirstImport
|
||||
? `Start Import (${stats.newFiles} files)`
|
||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||
|
||||
// Determine what to show in each card based on current phase
|
||||
const sessionStats = importSession.stats;
|
||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||
|
||||
const totalFiles = stats.totalLocalFiles;
|
||||
const importedCount = stats.alreadyImported;
|
||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||
|
||||
const showProgressBar = socketImport !== null;
|
||||
const socketProgressPct =
|
||||
socketImport && socketImport.total > 0
|
||||
? Math.round((socketImport.completed / socketImport.total) * 100)
|
||||
: 0;
|
||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||
const showMissingCard = missingCount > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{importError && (
|
||||
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
||||
Import Error
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||
{importError}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportError(null)}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File detected toast */}
|
||||
{detectedFile && (
|
||||
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
||||
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
|
||||
New file detected: {detectedFile}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Import button — only when idle with new files */}
|
||||
{hasNewFiles && !importSession.isActive && (
|
||||
<button
|
||||
onClick={handleStartImport}
|
||||
disabled={isStartingImport}
|
||||
className="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"
|
||||
>
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Progress bar — shown while importing and once complete */}
|
||||
{showProgressBar && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{socketImport!.active
|
||||
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
||||
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{socketProgressPct}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
||||
style={{ width: `${socketProgressPct}%` }}
|
||||
>
|
||||
{socketImport!.active && (
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{/* Total files */}
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{ backgroundColor: "#6b7280" }}
|
||||
>
|
||||
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
||||
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
||||
</div>
|
||||
|
||||
{/* Imported */}
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{ backgroundColor: "#d8dab2" }}
|
||||
>
|
||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{importedCount}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 font-medium">
|
||||
{importSession.isActive ? "imported so far" : "imported in database"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed — only shown after a session with failures */}
|
||||
{showFailedCard && (
|
||||
<div className="rounded-lg p-6 text-center bg-red-500">
|
||||
<div className="text-4xl font-bold text-white mb-2">
|
||||
{failedCount}
|
||||
</div>
|
||||
<div className="text-sm text-red-100 font-medium">failed</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing files — shown when watcher detects moved/deleted files */}
|
||||
{showMissingCard && (
|
||||
<div className="rounded-lg p-6 text-center bg-card-missing">
|
||||
<div className="text-4xl font-bold text-slate-700 mb-2">
|
||||
{missingCount}
|
||||
</div>
|
||||
<div className="text-sm text-slate-800 font-medium">missing</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Missing files detail panel */}
|
||||
{showMissingCard && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||
{missingCount} {missingCount === 1 ? "file" : "files"} missing
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
These files were previously imported but can no longer be found
|
||||
on disk. Move them back to restore access.
|
||||
</p>
|
||||
{missingDocs.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{missingDocs.map((comic, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-xs text-amber-700 dark:text-amber-400 truncate"
|
||||
>
|
||||
{getMissingComicLabel(comic)} is missing
|
||||
</li>
|
||||
))}
|
||||
{missingCount > 3 && (
|
||||
<li className="text-xs text-amber-600 dark:text-amber-500">
|
||||
and {missingCount - 3} more.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to="/library?filter=missingFiles"
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
||||
>
|
||||
|
||||
<span className="underline">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
|
||||
View Missing Files In Library
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeImportStats;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import React, { useMemo, ReactElement, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
import T2Table from "../shared/T2Table";
|
||||
@@ -12,215 +11,111 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { format, fromUnixTime, parseISO } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useGetWantedComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type FilterOption = "all" | "missingFiles";
|
||||
|
||||
interface SearchQuery {
|
||||
query: Record<string, any>;
|
||||
pagination: { size: number; from: number };
|
||||
type: string;
|
||||
trigger: string;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: "all", label: "All Comics" },
|
||||
{ value: "missingFiles", label: "Missing Files" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* <Library />
|
||||
* Library page component. Displays a paginated, searchable table of all comics
|
||||
* in the collection, with an optional filter for comics with missing raw files.
|
||||
*/
|
||||
export const Library = (): ReactElement => {
|
||||
// Default page state
|
||||
// offset: 0
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState({
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: offset,
|
||||
},
|
||||
pagination: { size: 25, from: 0 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||
*/
|
||||
const fetchIssues = async (searchQuery) => {
|
||||
const { pagination, query, type } = searchQuery;
|
||||
/** Fetches a page of issues from the search API. */
|
||||
const fetchIssues = async (q: SearchQuery) => {
|
||||
const { pagination, query, type } = q;
|
||||
return await axios({
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/api/search/searchIssue",
|
||||
data: {
|
||||
query,
|
||||
pagination,
|
||||
type,
|
||||
},
|
||||
data: { query, pagination, type },
|
||||
});
|
||||
};
|
||||
|
||||
const searchIssues = (e) => {
|
||||
const { data, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: activeFilter === "all",
|
||||
});
|
||||
|
||||
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 25, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "missingFiles" },
|
||||
);
|
||||
|
||||
const { data: missingIdsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 1000, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "all" },
|
||||
);
|
||||
|
||||
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
||||
const missingIdSet = useMemo(
|
||||
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
||||
[missingIdsData],
|
||||
);
|
||||
|
||||
const searchResults = data?.data;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
||||
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
||||
|
||||
/** Triggers a search by volume name and resets pagination. */
|
||||
const searchIssues = (e: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {
|
||||
volumeName: e.search,
|
||||
},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: 0,
|
||||
},
|
||||
query: { volumeName: e.search },
|
||||
pagination: { size: 15, from: 0 },
|
||||
type: "volumeName",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", offset, searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const searchResults = data?.data;
|
||||
// Programmatically navigate to comic detail
|
||||
const navigate = useNavigate();
|
||||
const navigateToComicDetail = (row) => {
|
||||
navigate(`/comic/details/${row.original._id}`);
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
|
||||
{/* Series Name */}
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{ellipsize(value.data.series[0], 45)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row mt-2 gap-2">
|
||||
{/* Pages */}
|
||||
<span className="inline-flex 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">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex 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">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{!isNil(value.data.number) && (
|
||||
<span>{parseInt(value.data.number[0], 10)}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
footer: 1,
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 400,
|
||||
accessorKey: "_source",
|
||||
cell: (info) => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
cell: (info) =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Additional Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the next x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
*
|
||||
**/
|
||||
/** Advances to the next page of results. */
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(pageSize * pageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the previous x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
**/
|
||||
/** Goes back to the previous page of results. */
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
@@ -231,86 +126,235 @@ export const Library = (): ReactElement => {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from,
|
||||
},
|
||||
pagination: { size: 15, from },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(from);
|
||||
};
|
||||
|
||||
// ImportStatus.propTypes = {
|
||||
// value: PropTypes.bool.isRequired,
|
||||
// };
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
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">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
|
||||
{ellipsize(value.data.series[0], 25)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{!isNil(value.data.number) && (
|
||||
<span>{parseInt(value.data.number[0], 10)}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : null;
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!isUndefined(searchResults?.hits) ? (
|
||||
<div>
|
||||
<div>
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
paginationHandlers={{
|
||||
nextPage,
|
||||
previousPage,
|
||||
}}
|
||||
>
|
||||
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||
</T2Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no
|
||||
indices. Try importing a few comics into the library and come
|
||||
back.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Comic",
|
||||
id: "missingComic",
|
||||
minWidth: 250,
|
||||
accessorFn: (row: any) => row,
|
||||
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 250,
|
||||
accessorKey: "_source",
|
||||
cell: (info: any) => {
|
||||
const source = info.getValue();
|
||||
return (
|
||||
<MetadataPanel
|
||||
data={source}
|
||||
isMissing={missingIdSet.has(info.row.original._id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
cell: (info: any) =>
|
||||
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Additional Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info: any) =>
|
||||
!isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info: any) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</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="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[missingIdSet],
|
||||
);
|
||||
|
||||
const FilterDropdown = () => (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={activeFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
||||
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isMissingFilter = activeFilter === "missingFiles";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMissingFilter ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{isMissingLoading ? (
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
) : (
|
||||
<T2Table
|
||||
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
|
||||
columns={missingFilesColumns}
|
||||
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
|
||||
rowClickHandler={navigateToMissingComicDetail}
|
||||
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
|
||||
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
|
||||
>
|
||||
<FilterDropdown />
|
||||
</T2Table>
|
||||
)}
|
||||
</div>
|
||||
) : !isUndefined(searchResults?.hits) ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
getRowClassName={(row) =>
|
||||
missingIdSet.has(row.original._id)
|
||||
? "bg-card-missing/40 hover:bg-card-missing/20"
|
||||
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
|
||||
}
|
||||
paginationHandlers={{ nextPage, previousPage }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterDropdown />
|
||||
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
|
||||
</div>
|
||||
</T2Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no indices. Try
|
||||
importing a few comics into the library and come back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<FilterDropdown />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Library;
|
||||
|
||||
@@ -72,10 +72,14 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
||||
>
|
||||
<div className="content is-flex is-flex-direction-row">
|
||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||
<span className="icon cv-icon is-small">
|
||||
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="icon has-text-info">
|
||||
<i className="fas fa-adjust" />
|
||||
|
||||
@@ -99,7 +99,7 @@ export const PullList = (): ReactElement => {
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<section className="container">
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="section">
|
||||
<div className="header-area">
|
||||
<h1 className="title">Weekly Pull List</h1>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useCallback, ReactElement, useState } from "react";
|
||||
import { isNil, isEmpty } from "lodash";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import Card from "../shared/Carda";
|
||||
import ellipsize from "ellipsize";
|
||||
import { convert } from "html-to-text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../../shared/utils/i18n.util";
|
||||
import PopoverButton from "../shared/PopoverButton";
|
||||
import dayjs from "dayjs";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
@@ -17,67 +20,127 @@ import axios from "axios";
|
||||
interface ISearchProps {}
|
||||
|
||||
export const Search = ({}: ISearchProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const formData = {
|
||||
search: "",
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [comicVineMetadata, setComicVineMetadata] = useState({});
|
||||
const getCVSearchResults = (searchQuery) => {
|
||||
setSearchQuery(searchQuery.search);
|
||||
const [selectedResource, setSelectedResource] = useState("volume");
|
||||
const { t } = useTranslation();
|
||||
const handleResourceChange = (value) => {
|
||||
setSelectedResource(value);
|
||||
};
|
||||
|
||||
const {
|
||||
mutate,
|
||||
data: comicVineSearchResults,
|
||||
isLoading,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
} = useMutation({
|
||||
mutationFn: async (data: { search: string; resource: string }) => {
|
||||
const { search, resource } = data;
|
||||
return await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/search`,
|
||||
method: "GET",
|
||||
params: {
|
||||
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
|
||||
query: searchQuery,
|
||||
query: search,
|
||||
format: "json",
|
||||
limit: "10",
|
||||
offset: "0",
|
||||
field_list:
|
||||
"id,name,deck,api_detail_url,image,description,volume,cover_date",
|
||||
resources: "issue",
|
||||
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
|
||||
resources: resource,
|
||||
},
|
||||
}),
|
||||
queryKey: ["comicvineSearchResults", searchQuery],
|
||||
enabled: !isNil(searchQuery),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// add to library
|
||||
const { data: additionResult } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
const { data: additionResult, mutate: addToWantedList } = useMutation({
|
||||
mutationFn: async ({
|
||||
source,
|
||||
comicObject,
|
||||
markEntireVolumeWanted,
|
||||
resourceType,
|
||||
}) => {
|
||||
let volumeInformation = {};
|
||||
let issues = [];
|
||||
switch (resourceType) {
|
||||
case "issue":
|
||||
const { id, api_detail_url, image, cover_date, issue_number } =
|
||||
comicObject;
|
||||
// Add issue metadata
|
||||
issues.push({
|
||||
id,
|
||||
url: api_detail_url,
|
||||
image,
|
||||
coverDate: cover_date,
|
||||
issueNumber: issue_number,
|
||||
});
|
||||
// Get volume metadata from CV
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
||||
method: "POST",
|
||||
data: {
|
||||
volumeURI: comicObject.volume.api_detail_url,
|
||||
fieldList:
|
||||
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
|
||||
},
|
||||
});
|
||||
// set volume metadata key
|
||||
volumeInformation = response.data?.results;
|
||||
break;
|
||||
|
||||
case "volume":
|
||||
const {
|
||||
id: volumeId,
|
||||
api_detail_url: apiUrl,
|
||||
image: volumeImage,
|
||||
name,
|
||||
publisher,
|
||||
} = comicObject;
|
||||
volumeInformation = {
|
||||
id: volumeId,
|
||||
url: apiUrl,
|
||||
image: volumeImage,
|
||||
name,
|
||||
publisher,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Add to wanted list
|
||||
return await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||
method: "POST",
|
||||
data: {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
isImported: false, // wanted, but not acquired yet.
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata:
|
||||
{ comicvine: comicVineMetadata?.comicData } || null,
|
||||
acquisition: { source: { wanted: true, name: "comicvine" } },
|
||||
wanted: {
|
||||
source,
|
||||
markEntireVolumeWanted,
|
||||
issues,
|
||||
volume: volumeInformation,
|
||||
},
|
||||
sourcedMetadata: { comicvine: volumeInformation },
|
||||
},
|
||||
},
|
||||
}),
|
||||
queryKey: ["additionResult"],
|
||||
enabled: !isNil(comicVineMetadata.comicData),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch wanted comics queries
|
||||
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||
},
|
||||
});
|
||||
|
||||
const addToLibrary = (sourceName: string, comicData) =>
|
||||
@@ -87,11 +150,20 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const formData = { ...values, resource: selectedResource };
|
||||
try {
|
||||
mutate(formData);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
@@ -107,7 +179,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
</header>
|
||||
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<Form
|
||||
onSubmit={getCVSearchResults}
|
||||
onSubmit={onSubmit}
|
||||
initialValues={{
|
||||
...formData,
|
||||
}}
|
||||
@@ -139,19 +211,73 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
{/* resource type selection: volume, issue etc. */}
|
||||
<div className="flex flex-row gap-3 mt-4">
|
||||
<Field name="resource" type="radio" value="volume">
|
||||
{({ input: volumesInput, meta }) => (
|
||||
<div className="w-fit rounded-xl">
|
||||
<div>
|
||||
<input
|
||||
{...volumesInput}
|
||||
type="radio"
|
||||
id="volume"
|
||||
checked={selectedResource === "volume"}
|
||||
onChange={() => handleResourceChange("volume")}
|
||||
className="peer hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="volume"
|
||||
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||
>
|
||||
Volumes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="resource" type="radio" value="issue">
|
||||
{({ input: issuesInput, meta }) => (
|
||||
<div className="w-fit rounded-xl">
|
||||
<div>
|
||||
<input
|
||||
{...issuesInput}
|
||||
type="radio"
|
||||
id="issue"
|
||||
checked={selectedResource === "issue"}
|
||||
onChange={() => handleResourceChange("issue")}
|
||||
className="peer hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="issue"
|
||||
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||
>
|
||||
Issues
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <>Loading kaka...</>}
|
||||
{!isNil(comicVineSearchResults?.data.results) &&
|
||||
!isEmpty(comicVineSearchResults?.data.results) ? (
|
||||
{isPending && (
|
||||
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
Loading results...
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{comicVineSearchResults.data.results.map((result) => {
|
||||
return isSuccess ? (
|
||||
<div key={result.id} className="mb-5">
|
||||
return result.resource_type === "issue" ? (
|
||||
<div
|
||||
key={result.id}
|
||||
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<div className="mr-5">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
@@ -159,7 +285,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<div className="w-3/4">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.volume.name) ? (
|
||||
result.volume.name
|
||||
@@ -167,27 +293,19 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
<span className="is-size-3">No Name</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="field is-grouped mt-1">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Cover date</span>
|
||||
<span className="tag is-info is-light">
|
||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.cover_date && (
|
||||
<p>
|
||||
<span className="tag is-light">Cover date</span>
|
||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-warning">{result.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="tag is-warning">{result.id}</p>
|
||||
|
||||
<a href={result.api_detail_url}>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
<p>
|
||||
<p className="text-sm">
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
@@ -198,19 +316,129 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<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-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => addToLibrary("comicvine", result)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||
Mark as Wanted
|
||||
</button>
|
||||
<PopoverButton
|
||||
content={`This will add ${result.volume.name} to your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
comicObject: result,
|
||||
markEntireVolumeWanted: false,
|
||||
resourceType: "issue",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Loading</div>
|
||||
result.resource_type === "volume" && (
|
||||
<div
|
||||
key={result.id}
|
||||
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.name) ? (
|
||||
result.name
|
||||
) : (
|
||||
<span className="text-xl">No Name</span>
|
||||
)}
|
||||
{result.start_year && <> ({result.start_year})</>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* issue count */}
|
||||
{result.count_of_issues && (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{t("issueWithCount", {
|
||||
count: result.count_of_issues,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* type: TPB, one-shot, graphic novel etc. */}
|
||||
{!isNil(result.description) &&
|
||||
!isUndefined(result.description) && (
|
||||
<>
|
||||
{!isEmpty(
|
||||
detectIssueTypes(result.description),
|
||||
) && (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(result.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="tag is-warning">{result.id}</span>
|
||||
<p>
|
||||
<a href={result.api_detail_url}>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* description */}
|
||||
<p className="text-sm">
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
selectors: ["p", "div"],
|
||||
},
|
||||
}),
|
||||
320,
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<PopoverButton
|
||||
content={`Adding this volume will add ${t(
|
||||
"issueWithCount",
|
||||
{
|
||||
count: result.count_of_issues,
|
||||
},
|
||||
)} to your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
comicObject: result,
|
||||
markEntireVolumeWanted: true,
|
||||
resourceType: "volume",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import Select from "react-select";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../../store";
|
||||
import axios from "axios";
|
||||
import { produce } from "immer";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||
|
||||
export const AirDCPPHubsForm = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
} = useStore((state) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
}));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
@@ -29,23 +22,31 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: settings?.data.directConnect?.client?.host,
|
||||
},
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
});
|
||||
let hubList = {};
|
||||
|
||||
let hubList: any[] = [];
|
||||
if (!isNil(hubs)) {
|
||||
hubList = hubs.map(({ hub_url, identity }) => ({
|
||||
hubList = hubs?.data.map(({ hub_url, identity }) => ({
|
||||
value: hub_url,
|
||||
label: identity.name,
|
||||
}));
|
||||
}
|
||||
const { mutate } = useMutation({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
@@ -56,79 +57,112 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
||||
settingsKey: "directConnect",
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["settings"], (oldData: any) =>
|
||||
produce(oldData, (draft: any) => {
|
||||
draft.data.directConnect.client = {
|
||||
...draft.data.directConnect.client,
|
||||
...data.data.directConnect.client,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const validate = async () => {};
|
||||
|
||||
const validate = async (values) => {
|
||||
const errors = {};
|
||||
// Add any validation logic here if needed
|
||||
return errors;
|
||||
};
|
||||
|
||||
const SelectAdapter = ({ input, ...rest }) => {
|
||||
return <Select {...input} {...rest} isClearable isMulti />;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading settings.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(hubList) && !isUndefined(hubs) ? (
|
||||
<Form
|
||||
onSubmit={mutate}
|
||||
onSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}
|
||||
validate={validate}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<h3 className="title">Hubs</h3>
|
||||
<form onSubmit={handleSubmit} className="mt-10">
|
||||
<h2 className="text-xl">Configure DC++ Hubs</h2>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<h6 className="subtitle has-text-grey-light">
|
||||
Select the hubs you want to perform searches against.
|
||||
Select the hubs you want to perform searches against. Your
|
||||
selection in the dropdown <strong>will replace</strong> the
|
||||
existing selection.
|
||||
</h6>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">AirDC++ Host</label>
|
||||
<div className="control">
|
||||
<Field
|
||||
name="hubs"
|
||||
component={SelectAdapter}
|
||||
className="basic-multi-select"
|
||||
placeholder="Select Hubs to Search Against"
|
||||
options={hubList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<button type="submit" className="button is-primary">
|
||||
<div className="field">
|
||||
<label className="block py-1 mt-3">AirDC++ Host</label>
|
||||
<Field
|
||||
name="hubs"
|
||||
component={SelectAdapter}
|
||||
className="basic-multi-select"
|
||||
placeholder="Select Hubs to Search Against"
|
||||
options={hubList}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<article className="message">
|
||||
<div className="message-body">
|
||||
No configured hubs detected in AirDC++. <br />
|
||||
Configure to a hub in AirDC++ and then select a default hub here.
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 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 className="message-body">
|
||||
No configured hubs detected in AirDC++. <br />
|
||||
Configure to a hub in AirDC++ and then select a default hub here.
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<article className="message is-warning">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
Your selection in the dropdown <strong>will replace</strong> the
|
||||
existing selection.
|
||||
</div>
|
||||
<div className="message-body is-size-6 is-family-secondary"></div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="box mt-3">
|
||||
<h6>Default Hub For Searches:</h6>
|
||||
{settings?.data.directConnect?.client.hubs.map(
|
||||
({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div>
|
||||
<span className="flex items-center mt-10 mb-4">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Default Hub for Searches
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
{settings?.data.directConnect?.client.hubs.map(
|
||||
({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,76 +1,64 @@
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||
import { isUndefined, isEmpty } from "lodash";
|
||||
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
AIRDCPP_SERVICE_BASE_URI,
|
||||
SETTINGS_SERVICE_BASE_URI,
|
||||
} from "../../../constants/endpoints";
|
||||
|
||||
export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
// cherry-picking selectors for:
|
||||
// 1. initial values for the form
|
||||
// 2. If initial values are present, get the socket information to display
|
||||
const { setState } = useStore;
|
||||
const {
|
||||
airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
|
||||
})),
|
||||
);
|
||||
export const AirDCPPSettingsForm = () => {
|
||||
const [airDCPPSessionInformation, setAirDCPPSessionInformation] =
|
||||
useState(null);
|
||||
// Fetching all settings
|
||||
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({
|
||||
queryKey: ["airDCPPSettings"],
|
||||
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation to update settings and subsequently initialize
|
||||
* AirDC++ socket with those settings
|
||||
*/
|
||||
// Fetch session information
|
||||
const fetchSessionInfo = (host) => {
|
||||
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
||||
};
|
||||
|
||||
// Use effect to trigger side effects on settings fetch success
|
||||
useEffect(() => {
|
||||
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) {
|
||||
const host = settingsData.data.directConnect.client.host;
|
||||
fetchSessionInfo(host).then((response) => {
|
||||
setAirDCPPSessionInformation(response.data);
|
||||
});
|
||||
}
|
||||
}, [settingsSuccess, settingsData]);
|
||||
|
||||
// Handle setting update and subsequent AirDC++ initialization
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
method: "POST",
|
||||
data: { settingsPayload: values, settingsKey: "directConnect" },
|
||||
}),
|
||||
onSuccess: async (values) => {
|
||||
const {
|
||||
data: {
|
||||
directConnect: {
|
||||
client: { host },
|
||||
},
|
||||
},
|
||||
} = values;
|
||||
const dcppSocketInstance = await initializeAirDCPPSocket(host);
|
||||
setState({
|
||||
airDCPPClientConfiguration: host,
|
||||
airDCPPSocketInstance: dcppSocketInstance,
|
||||
mutationFn: (values) => {
|
||||
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: values,
|
||||
settingsKey: "directConnect",
|
||||
});
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
const host = response?.data?.directConnect?.client?.host;
|
||||
if (host) {
|
||||
const response = await fetchSessionInfo(host);
|
||||
setAirDCPPSessionInformation(response.data);
|
||||
// setState({ airDCPPClientConfiguration: host });
|
||||
}
|
||||
},
|
||||
});
|
||||
const deleteSettingsMutation = useMutation(
|
||||
async () =>
|
||||
await axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: {},
|
||||
settingsKey: "directConnect",
|
||||
}),
|
||||
|
||||
const deleteSettingsMutation = useMutation(() =>
|
||||
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: {},
|
||||
settingsKey: "directConnect",
|
||||
}),
|
||||
);
|
||||
|
||||
// const removeSettings = useCallback(async () => {
|
||||
// // airDCPPSettings.setSettings({});
|
||||
// }, []);
|
||||
//
|
||||
const initFormData = !isUndefined(airDCPPClientConfiguration)
|
||||
? airDCPPClientConfiguration
|
||||
: {};
|
||||
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionForm
|
||||
@@ -79,13 +67,12 @@ export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
formHeading={"Configure AirDC++"}
|
||||
/>
|
||||
|
||||
{!isEmpty(airDCPPSessionInformation) ? (
|
||||
{airDCPPSessionInformation && (
|
||||
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{!isEmpty(airDCPPClientConfiguration) ? (
|
||||
{settingsData?.data && (
|
||||
<p className="control mt-4">
|
||||
as
|
||||
<button
|
||||
className="button is-danger"
|
||||
onClick={() => deleteSettingsMutation.mutate()}
|
||||
@@ -93,7 +80,7 @@ export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
37
src/client/components/Settings/DockerVars/DockerVars.tsx
Normal file
37
src/client/components/Settings/DockerVars/DockerVars.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export const DockerVars = (): React.ReactElement => {
|
||||
const [environmentVariables, setEnvironmentVariables] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const { data } = useQuery({
|
||||
queryKey: ["docker-vars"],
|
||||
queryFn: async () => {
|
||||
await axios({
|
||||
method: "GET",
|
||||
url: "http://localhost:3000/api/settings/getEnvironmentVariables",
|
||||
}).then((response) => {
|
||||
setEnvironmentVariables(response.data);
|
||||
});
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
<pre>
|
||||
{Object.entries(environmentVariables).length > 0
|
||||
? JSON.stringify(environmentVariables, null, 2)
|
||||
: "No environment variables found."}
|
||||
</pre>
|
||||
</p>
|
||||
{/* Add your form or content for Docker environment variables here */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerVars;
|
||||
@@ -19,7 +19,6 @@ export const ProwlarrSettingsForm = (props) => {
|
||||
},
|
||||
queryKey: ["prowlarrConnectionResult"],
|
||||
});
|
||||
console.log(data);
|
||||
const submitHandler = () => {};
|
||||
const initialData = {};
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
|
||||
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
|
||||
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
|
||||
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
|
||||
import DockerVars from "./DockerVars/DockerVars";
|
||||
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
|
||||
import settingsObject from "../../constants/settings/settingsMenu.json";
|
||||
import { isUndefined, map } from "lodash";
|
||||
@@ -12,139 +13,130 @@ interface ISettingsProps {}
|
||||
|
||||
export const Settings = (props: ISettingsProps): ReactElement => {
|
||||
const [active, setActive] = useState("gen-db");
|
||||
console.log(active);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const settingsContent = [
|
||||
{
|
||||
id: "adc-hubs",
|
||||
content: (
|
||||
<div key="adc-hubs">
|
||||
<AirDCPPHubsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "adc-connection",
|
||||
content: (
|
||||
<div key="adc-connection">
|
||||
<AirDCPPSettingsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "qbt-connection",
|
||||
content: (
|
||||
<div key="qbt-connection">
|
||||
<QbittorrentConnectionForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "prwlr-connection",
|
||||
content: (
|
||||
<>
|
||||
<ProwlarrSettingsForm />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "core-service",
|
||||
content: <>a</>,
|
||||
},
|
||||
{
|
||||
id: "flushdb",
|
||||
content: (
|
||||
<div key="flushdb">
|
||||
<SystemSettingsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: "adc-hubs", content: <AirDCPPHubsForm /> },
|
||||
{ id: "adc-connection", content: <AirDCPPSettingsForm /> },
|
||||
{id: "gen-docker-vars", content: <DockerVars />},
|
||||
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> },
|
||||
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> },
|
||||
{ id: "core-service", content: <>a</> },
|
||||
{ id: "flushdb", content: <SystemSettingsForm /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
{/* Header */}
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-white">
|
||||
Import comics into the ThreeTwo library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-row">
|
||||
<div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
|
||||
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{map(settingsObject, (settingObject, idx) => {
|
||||
return (
|
||||
|
||||
{/* Main Layout */}
|
||||
<div className="flex gap-8 px-12 py-6">
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-30">
|
||||
<aside
|
||||
className="sticky top-6 w-72 max-h-[90vh]
|
||||
rounded-2xl shadow-xl backdrop-blur-md
|
||||
bg-white/70 dark:bg-slate-800/60
|
||||
border border-slate-200 dark:border-slate-700
|
||||
overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-6 overflow-y-auto">
|
||||
{map(settingsObject, (settingObject, idx) => (
|
||||
<div
|
||||
className="w-64 py-2 text-slate-700 dark:text-slate-400"
|
||||
key={idx}
|
||||
className="mb-6 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<h3 className="text-l pb-2">
|
||||
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
|
||||
{settingObject.category.toUpperCase()}
|
||||
</h3>
|
||||
{/* First level children */}
|
||||
{!isUndefined(settingObject.children) ? (
|
||||
<ul key={settingObject.id}>
|
||||
|
||||
{!isUndefined(settingObject.children) && (
|
||||
<ul>
|
||||
{map(settingObject.children, (item, idx) => {
|
||||
const isOpen = expanded[item.id];
|
||||
|
||||
return (
|
||||
<li key={idx} className="mb-2">
|
||||
<a
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() => setActive(item.id.toString())}
|
||||
<li key={idx} className="mb-1">
|
||||
<div
|
||||
onClick={() => toggleExpanded(item.id)}
|
||||
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||
item.id === active
|
||||
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.displayName}
|
||||
</a>
|
||||
{/* Second level children */}
|
||||
{!isUndefined(item.children) ? (
|
||||
<ul className="pl-4 mt-2">
|
||||
{map(item.children, (item, idx) => (
|
||||
<li key={item.id} className="mb-2">
|
||||
<span
|
||||
onClick={() => setActive(item.id.toString())}
|
||||
className="flex-1"
|
||||
>
|
||||
{item.displayName}
|
||||
</span>
|
||||
{!isUndefined(item.children) && (
|
||||
<span className="text-xs opacity-60">
|
||||
{isOpen ? "−" : "+"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUndefined(item.children) && isOpen && (
|
||||
<ul className="pl-4 mt-1">
|
||||
{map(item.children, (subItem) => (
|
||||
<li key={subItem.id} className="mb-1">
|
||||
<a
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() =>
|
||||
setActive(item.id.toString())
|
||||
setActive(subItem.id.toString())
|
||||
}
|
||||
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||
subItem.id.toString() === active
|
||||
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.displayName}
|
||||
{subItem.displayName}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* content for settings */}
|
||||
<div className="flex mx-12">
|
||||
<div className="">
|
||||
{map(settingsContent, ({ id, content }) =>
|
||||
active === id ? content : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<main className="flex-1 px-2 py-2">
|
||||
{settingsContent.map(({ id, content }) =>
|
||||
active === id ? <div key={id}>{content}</div> : null,
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -48,13 +48,11 @@ export const SystemSettingsForm = (): ReactElement => {
|
||||
</article>
|
||||
|
||||
<button
|
||||
className={
|
||||
isLoading ? "button is-danger is-loading" : "button is-danger"
|
||||
}
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => flushDb()}
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-eraser"></i>
|
||||
<span className="pt-1 px-1">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
|
||||
</span>
|
||||
<span>Flush DB & Temporary Folders</span>
|
||||
</button>
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { useEffect, ReactElement, useState, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { ReactElement, useState, useCallback } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
getComicBookDetailById,
|
||||
getIssuesForSeries,
|
||||
analyzeLibrary,
|
||||
} from "../../actions/comicinfo.actions";
|
||||
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { Card } from "../shared/Carda";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
|
||||
const VolumeDetails = (props): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 6,
|
||||
1100: 4,
|
||||
700: 3,
|
||||
500: 2,
|
||||
};
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [storyArcsData, setStoryArcsData] = useState([]);
|
||||
const [active, setActive] = useState(1);
|
||||
|
||||
// sliding panel init
|
||||
@@ -33,7 +28,9 @@ const VolumeDetails = (props): ReactElement => {
|
||||
content: () => {
|
||||
const ids = map(matches, partialRight(pick, "_id"));
|
||||
const matchIds = ids.map((id: any) => id._id);
|
||||
return <PotentialLibraryMatches matches={matchIds} />;
|
||||
{
|
||||
/* return <PotentialLibraryMatches matches={matchIds} />; */
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -45,68 +42,146 @@ const VolumeDetails = (props): ReactElement => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const analyzeIssues = useCallback((issues) => {
|
||||
dispatch(analyzeLibrary(issues));
|
||||
}, []);
|
||||
|
||||
const comicBookDetails = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const issuesForVolume = useSelector(
|
||||
(state: RootState) => state.comicInfo.issuesForVolume,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(getIssuesForSeries(comicObjectId));
|
||||
dispatch(getComicBookDetailById(comicObjectId));
|
||||
}, []);
|
||||
// const analyzeIssues = useCallback((issues) => {
|
||||
// dispatch(analyzeLibrary(issues));
|
||||
// }, []);
|
||||
//
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
|
||||
const { data: comicObject, isSuccess: isComicObjectFetchedSuccessfully } =
|
||||
useQuery({
|
||||
queryFn: async () =>
|
||||
axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicObjectId,
|
||||
},
|
||||
}),
|
||||
queryKey: ["comicObject"],
|
||||
});
|
||||
|
||||
// get issues for a series
|
||||
const {
|
||||
data: issuesForSeries,
|
||||
isSuccess,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getIssuesForVolume`,
|
||||
method: "POST",
|
||||
data: {
|
||||
volumeId:
|
||||
comicObject?.data?.sourcedMetadata.comicvine.volumeInformation.id,
|
||||
},
|
||||
}),
|
||||
queryKey: ["issuesForSeries", comicObject?.data],
|
||||
enabled: !isUndefined(comicObject?.data),
|
||||
});
|
||||
// get story arcs
|
||||
const useGetStoryArcs = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (comicObject) =>
|
||||
axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getResource`,
|
||||
method: "POST",
|
||||
data: {
|
||||
comicObject,
|
||||
resource: "issue",
|
||||
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setStoryArcsData(data?.data.results);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: getStoryArcs,
|
||||
isIdle,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
status,
|
||||
} = useGetStoryArcs();
|
||||
|
||||
const IssuesInVolume = () => (
|
||||
<>
|
||||
{!isUndefined(issuesForVolume) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
|
||||
{!isUndefined(issuesForSeries) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
|
||||
Analyze Library
|
||||
</div>
|
||||
) : null}
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="issues-container"
|
||||
columnClassName="issues-column"
|
||||
>
|
||||
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
|
||||
? issuesForVolume.map((issue) => {
|
||||
return (
|
||||
<>
|
||||
{isSuccess &&
|
||||
issuesForSeries.data.map((issue) => {
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
key={issue.id}
|
||||
imageUrl={issue.image.thumb_url}
|
||||
orientation={"vertical"}
|
||||
hasDetails
|
||||
borderColorClass={
|
||||
!isEmpty(issue.matches) ? "green-border" : ""
|
||||
}
|
||||
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
|
||||
onClick={() =>
|
||||
openPotentialLibraryMatchesPanel(issue.matches)
|
||||
}
|
||||
>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
: "loading"}
|
||||
</Masonry>
|
||||
imageUrl={issue.image.small_url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
const Issues = () => (
|
||||
<>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
You can add a single issue or the whole volume, and it will be added
|
||||
to the list of `Wanted` items.
|
||||
</div>
|
||||
</article>
|
||||
<div className="flex flex-wrap">
|
||||
{isSuccess &&
|
||||
issuesForSeries?.data.map((issue) => {
|
||||
return (
|
||||
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
|
||||
<div className="flex flex-row gap-4 mb-2">
|
||||
<div className="w-fit">
|
||||
<img
|
||||
src={issue.image.thumb_url}
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<p className="text-xl">{issue.name}</p>
|
||||
<p className="text-sm">
|
||||
{convert(issue.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -115,20 +190,44 @@ const VolumeDetails = (props): ReactElement => {
|
||||
{
|
||||
id: 1,
|
||||
name: "Issues in Volume",
|
||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||
content: <IssuesInVolume key={1} />,
|
||||
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
|
||||
content: <Issues />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: <i className="fa-regular fa-mask"></i>,
|
||||
icon: (
|
||||
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
name: "Characters",
|
||||
content: <div key={2}>asdasd</div>,
|
||||
content: <div key={2}>Characters</div>,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <i className="fa-solid fa-scroll"></i>,
|
||||
name: "Arcs",
|
||||
content: <div key={3}>asdasd</div>,
|
||||
icon: (
|
||||
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
name: "Story Arcs",
|
||||
content: (
|
||||
<div key={3}>
|
||||
<button className="" onClick={() => getStoryArcs(comicObject?.data)}>
|
||||
Get story arcs
|
||||
</button>
|
||||
{status === "pending" && <>{status}</>}
|
||||
{!isEmpty(storyArcsData) && status === "success" && (
|
||||
<>
|
||||
<ul>
|
||||
{storyArcsData.map((storyArc) => {
|
||||
return (
|
||||
<li>
|
||||
<span className="text-lg">{storyArc?.name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,21 +235,26 @@ const VolumeDetails = (props): ReactElement => {
|
||||
const MetadataTabGroup = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<li
|
||||
key={id}
|
||||
className={id === active ? "is-active" : ""}
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<a>
|
||||
<span className="icon is-small">{icon}</span>
|
||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4" aria-label="Tabs">
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<span className="pt-1">{icon}</span>
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{tabGroup.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
@@ -158,97 +262,103 @@ const VolumeDetails = (props): ReactElement => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
!isUndefined(comicBookDetails.sourcedMetadata) &&
|
||||
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
|
||||
) {
|
||||
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
|
||||
const { sourcedMetadata } = comicObject.data;
|
||||
return (
|
||||
<div className="container volume-details">
|
||||
<div className="section">
|
||||
{/* Title */}
|
||||
<h1 className="title">
|
||||
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</h1>
|
||||
<div className="columns is-multiline">
|
||||
{/* Volume cover */}
|
||||
<div className="column is-narrow">
|
||||
<>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Volumes
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your collection of volumes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 mt-4">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
{/* Volume cover */}
|
||||
<Card
|
||||
imageUrl={
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.image.small_url
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
}
|
||||
cardContainerStyle={{ maxWidth: 275 }}
|
||||
orientation={"vertical"}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="column is-three-fifths">
|
||||
<div className="field is-grouped mt-2">
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.id
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Publisher */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-warning is-light">Publisher</span>
|
||||
<span className="tag is-volume-related">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.publisher.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.description,
|
||||
<div className="field is-grouped">
|
||||
{/* Title */}
|
||||
<span className="text-2xl">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</span>
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{sourcedMetadata.comicvine.volumeInformation.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Publisher */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-warning is-light">Publisher</span>
|
||||
<span className="tag is-volume-related">
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
sourcedMetadata.comicvine.volumeInformation.publisher
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
</div>
|
||||
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</div>
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Card from "../shared/Carda";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import ellipsize from "ellipsize";
|
||||
import { convert } from "html-to-text";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
@@ -32,42 +33,44 @@ export const Volumes = (props): ReactElement => {
|
||||
}),
|
||||
queryKey: ["volumes"],
|
||||
});
|
||||
console.log(volumes);
|
||||
const columnData = useMemo(
|
||||
(): any => [
|
||||
{
|
||||
header: "Volume Details",
|
||||
id: "volumeDetails",
|
||||
minWidth: 450,
|
||||
accessorKey: "_source",
|
||||
accessorFn: (row) => row,
|
||||
cell: (row): any => {
|
||||
const foo = row.getValue();
|
||||
const comicObject = row.getValue();
|
||||
const {
|
||||
_source: { sourcedMetadata },
|
||||
} = comicObject;
|
||||
return (
|
||||
<div className="flex flex-row gap-3 mt-5">
|
||||
<Card
|
||||
imageUrl={
|
||||
foo.sourcedMetadata.comicvine.volumeInformation.image
|
||||
.small_url
|
||||
}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
<div className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg h-fit">
|
||||
<span className="text-xl mb-1">
|
||||
{foo.sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</span>
|
||||
<Link to={`/volume/details/${comicObject._id}`}>
|
||||
<Card
|
||||
imageUrl={
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</Link>
|
||||
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3">
|
||||
<div className="text-xl mb-1 w-fit">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</div>
|
||||
<p>
|
||||
{ellipsize(
|
||||
convert(
|
||||
foo.sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
),
|
||||
120,
|
||||
180,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -138,7 +141,7 @@ export const Volumes = (props): ReactElement => {
|
||||
<div>
|
||||
<section className="">
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
@@ -153,7 +156,7 @@ export const Volumes = (props): ReactElement => {
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess ? (
|
||||
<div>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={volumes?.data.hits.hits}
|
||||
@@ -162,6 +165,7 @@ export const Volumes = (props): ReactElement => {
|
||||
nextPage: () => {},
|
||||
previousPage: () => {},
|
||||
}}
|
||||
rowClickHandler={() => {}}
|
||||
columns={columnData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,6 @@ export const WantedComics = (props): ReactElement => {
|
||||
minWidth: 350,
|
||||
accessorFn: (data) => data,
|
||||
cell: (value) => {
|
||||
console.log("ASDASd", value);
|
||||
const row = value.getValue()._source;
|
||||
return row && <MetadataPanel data={row} />;
|
||||
},
|
||||
@@ -160,7 +159,7 @@ export const WantedComics = (props): ReactElement => {
|
||||
<div className="">
|
||||
<section className="">
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
@@ -175,7 +174,7 @@ export const WantedComics = (props): ReactElement => {
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess && wantedComics?.data.hits?.hits ? (
|
||||
<div>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={wantedComics?.data.hits.hits}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
||||
import {
|
||||
removeLeadingPeriod,
|
||||
escapePoundSymbol,
|
||||
} from "../shared/utils/formatting.utils";
|
||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
interface IProps {
|
||||
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
||||
mongoObjId?: number;
|
||||
hasTitle: boolean;
|
||||
title?: string;
|
||||
isHorizontal: boolean;
|
||||
}
|
||||
interface IState {}
|
||||
|
||||
class Card extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public drawCoverCard = (
|
||||
metadata: IExtractedComicBookCoverFile,
|
||||
): JSX.Element => {
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
||||
);
|
||||
const filePath = escapePoundSymbol(encodedFilePath);
|
||||
return (
|
||||
<div>
|
||||
<div className="card generic-card">
|
||||
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
||||
<div className="card-image">
|
||||
<figure className="image">
|
||||
<img src={filePath} alt="Placeholder image" />
|
||||
</figure>
|
||||
</div>
|
||||
{this.props.hasTitle && (
|
||||
<div className="card-content">
|
||||
<ul>
|
||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||
<li className="has-text-weight-semibold">
|
||||
{ellipsize(metadata.name, 18)}
|
||||
</li>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
{!isUndefined(this.props.comicBookCoversMetadata) &&
|
||||
!isEmpty(this.props.comicBookCoversMetadata) &&
|
||||
this.drawCoverCard(this.props.comicBookCoversMetadata)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -10,11 +10,31 @@ interface ICardProps {
|
||||
children?: PropTypes.ReactNodeLike;
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: PropTypes.object;
|
||||
imageStyle?: PropTypes.object;
|
||||
cardContainerStyle?: React.CSSProperties;
|
||||
imageStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const getCardStateClass = (cardState?: string): string => {
|
||||
switch (cardState) {
|
||||
case "wanted":
|
||||
return "bg-card-wanted";
|
||||
case "delete":
|
||||
return "bg-card-delete";
|
||||
case "scraped":
|
||||
return "bg-card-scraped";
|
||||
case "uncompressed":
|
||||
return "bg-card-uncompressed";
|
||||
case "imported":
|
||||
return "bg-card-imported";
|
||||
case "missing":
|
||||
return "bg-card-missing";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderCard = (props: ICardProps): ReactElement => {
|
||||
switch (props.orientation) {
|
||||
case "horizontal":
|
||||
@@ -83,12 +103,23 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
|
||||
case "vertical-2":
|
||||
return (
|
||||
<div className="block rounded-md w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
/>
|
||||
<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">
|
||||
{props.imageUrl ? (
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
|
||||
)}
|
||||
{props.cardState === "missing" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-16 h-16 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.title ? (
|
||||
<div className="px-3 pt-3 mb-2">
|
||||
@@ -109,7 +140,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
case "horizontal-small":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
|
||||
<div className={`flex flex-row justify-start align-top gap-3 h-fit rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<img src={props.imageUrl} className="object-cover h-20 w-20" />
|
||||
@@ -125,7 +156,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
case "horizontal-medium":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
|
||||
<div className={`flex flex-row items-center align-top gap-3 h-fit p-2 rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<img src={props.imageUrl} />
|
||||
|
||||
@@ -1,71 +1,42 @@
|
||||
import React, { ChangeEventHandler, useRef, useState } from "react";
|
||||
|
||||
import { format, isValid, parse, parseISO } from "date-fns";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import { DayPicker, SelectSingleEventHandler } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ClassNames, DayPicker } from "react-day-picker";
|
||||
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
|
||||
import styles from "react-day-picker/dist/style.module.css";
|
||||
|
||||
export const DatePickerDialog = (props) => {
|
||||
const { setter, apiAction } = props;
|
||||
const [selected, setSelected] = useState<Date>();
|
||||
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
||||
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const customStyles = {
|
||||
container: {
|
||||
// Style for the entire container
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
padding: "10px",
|
||||
width: "300px",
|
||||
},
|
||||
day: {
|
||||
// Style for individual days
|
||||
|
||||
padding: "5px",
|
||||
margin: "2px",
|
||||
},
|
||||
selected: {
|
||||
// Style for selected days
|
||||
backgroundColor: "#007bff",
|
||||
color: "#fff",
|
||||
},
|
||||
disabled: {
|
||||
// Style for disabled days
|
||||
color: "#ccc",
|
||||
},
|
||||
today: {
|
||||
// Style for today's date
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
dayWrapper: {
|
||||
// Style for the wrapper around each day
|
||||
display: "inline-block",
|
||||
},
|
||||
const classNames: ClassNames = {
|
||||
...styles,
|
||||
head: "custom-head",
|
||||
};
|
||||
|
||||
const popper = usePopper(popperRef.current, popperElement, {
|
||||
placement: "bottom-start",
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { x, y, reference, floating, strategy, refs, update } = useFloating({
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(10), flip()],
|
||||
strategy: "absolute",
|
||||
});
|
||||
|
||||
const closePopper = () => {
|
||||
setIsPopperOpen(false);
|
||||
buttonRef?.current?.focus();
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setIsPopperOpen(true);
|
||||
if (refs.reference.current && refs.floating.current) {
|
||||
autoUpdate(refs.reference.current, refs.floating.current, update);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaySelect: SelectSingleEventHandler = (date) => {
|
||||
const handleDaySelect = (date) => {
|
||||
setSelected(date);
|
||||
if (date) {
|
||||
setter(format(date, "M-dd-yyyy"));
|
||||
setter(format(date, "yyyy/MM/dd"));
|
||||
apiAction();
|
||||
closePopper();
|
||||
} else {
|
||||
@@ -75,17 +46,14 @@ export const DatePickerDialog = (props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={popperRef}>
|
||||
<div ref={reference}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Pick a date"
|
||||
onClick={handleButtonClick}
|
||||
className="flex space-x-1 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"
|
||||
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"
|
||||
>
|
||||
<span className="pr-1 pt-0.5 h-8">
|
||||
<span className="icon-[solar--calendar-date-bold-duotone] w-6 h-6"></span>
|
||||
</span>
|
||||
Pick a date
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,11 +69,14 @@ export const DatePickerDialog = (props) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
style={popper.styles.popper}
|
||||
className="bg-slate-200 mt-3 p-2 rounded-lg z-50"
|
||||
{...popper.attributes.popper}
|
||||
ref={setPopperElement}
|
||||
ref={floating}
|
||||
style={{
|
||||
position: strategy,
|
||||
zIndex: "999",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
|
||||
}}
|
||||
className="bg-slate-400 dark:bg-slate-500"
|
||||
role="dialog"
|
||||
aria-label="DayPicker calendar"
|
||||
>
|
||||
@@ -115,7 +86,7 @@ export const DatePickerDialog = (props) => {
|
||||
defaultMonth={selected}
|
||||
selected={selected}
|
||||
onSelect={handleDaySelect}
|
||||
styles={customStyles}
|
||||
classNames={classNames}
|
||||
/>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -66,6 +66,7 @@ export const DnD = (data) => {
|
||||
>
|
||||
<div className="box p-2 control-palette">
|
||||
<span className="tag is-warning mr-2">{index}</span>
|
||||
{/* TODO: Switch to Solar icons */}
|
||||
<span className="icon is-small mr-2">
|
||||
<i className="fa-solid fa-vial"></i>
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
type IHeaderProps = {
|
||||
headerContent: string;
|
||||
subHeaderContent: ReactElement;
|
||||
subHeaderContent: ReactElement | string;
|
||||
iconClassNames: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { find, isUndefined } from "lodash";
|
||||
|
||||
interface IMetadatPanelProps {
|
||||
value: any;
|
||||
children: any;
|
||||
imageStyle: any;
|
||||
titleStyle: any;
|
||||
tagsStyle: any;
|
||||
containerStyle: any;
|
||||
data: any;
|
||||
value?: any;
|
||||
children?: any;
|
||||
imageStyle?: any;
|
||||
titleStyle?: any;
|
||||
tagsStyle?: any;
|
||||
containerStyle?: any;
|
||||
isMissing?: boolean;
|
||||
}
|
||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const { isMissing = false } = props;
|
||||
const {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
@@ -31,11 +34,13 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
{
|
||||
name: "rawFileDetails",
|
||||
content: () => (
|
||||
<dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg">
|
||||
<dt>
|
||||
<p className="text-lg">{issueName}</p>
|
||||
<dl
|
||||
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
|
||||
>
|
||||
<dt className="flex items-center gap-2">
|
||||
<p className="text-sm sm:text-lg">{issueName}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<dd className="text-xs sm:text-sm">
|
||||
is a part of{" "}
|
||||
<span className="underline">
|
||||
{inferredMetadata.issue.name}
|
||||
@@ -50,34 +55,43 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
<dd className="flex flex-row gap-2 w-max">
|
||||
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
{rawFileDetails.mimeType && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
||||
{rawFileDetails.fileSize != null && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
{/* Missing file Icon */}
|
||||
{isMissing && (
|
||||
<span className="pr-2 pt-1" title="File backing this comic is missing">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-5 h-5 text-red-600 shrink-0"></i>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncompressed version available? */}
|
||||
{rawFileDetails.archive?.uncompressed && (
|
||||
@@ -177,16 +191,17 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const metadataPanel = find(metadataContentPanel, {
|
||||
name: objectReference,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 my-3">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
<div>{metadataPanel.content()}</div>
|
||||
<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">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">{metadataPanel?.content()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import React from "react";
|
||||
import { SearchBar } from "../GlobalSearchBar/SearchBar";
|
||||
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { format, fromUnixTime } from "date-fns";
|
||||
import { useStore } from "../../store/index";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
const Navbar: React.FunctionComponent = (props) => {
|
||||
const {
|
||||
airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPDownloadTick,
|
||||
importJobQueue,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
||||
importJobQueue: state.importJobQueue,
|
||||
})),
|
||||
);
|
||||
// const downloadProgressTick = useSelector(
|
||||
// (state: RootState) => state.airdcpp.downloadProgressData,
|
||||
// );
|
||||
//
|
||||
// const airDCPPSocketConnectionStatus = useSelector(
|
||||
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
|
||||
// );
|
||||
// const airDCPPSessionInfo = useSelector(
|
||||
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
|
||||
// );
|
||||
// const socketDisconnectionReason = useSelector(
|
||||
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
|
||||
// );
|
||||
|
||||
return (
|
||||
<nav className="navbar is-fixed-top">
|
||||
<div className="navbar-brand">
|
||||
<Link to="/dashboard" className="navbar-item">
|
||||
<img
|
||||
src="/src/client/assets/img/threetwo.svg"
|
||||
alt="ThreeTwo! A comic book curator"
|
||||
width="112"
|
||||
height="28"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<a className="navbar-item is-hidden-desktop">
|
||||
<span className="icon">
|
||||
<i className="fas fa-github"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a className="navbar-item is-hidden-desktop">
|
||||
<span className="icon">
|
||||
<i className="fas fa-twitter"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div className="navbar-burger burger" data-target="navMenubd-example">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="navMenubd-example" className="navbar-menu">
|
||||
<div className="navbar-start">
|
||||
<Link to="/" className="navbar-item">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Link to="/import" className="navbar-item">
|
||||
Import
|
||||
</Link>
|
||||
|
||||
<Link to="/library" className="navbar-item">
|
||||
Library
|
||||
</Link>
|
||||
|
||||
<Link to="/downloads" className="navbar-item">
|
||||
Downloads
|
||||
</Link>
|
||||
|
||||
{/* <SearchBar /> */}
|
||||
|
||||
<Link to="/search" className="navbar-item">
|
||||
Search ComicVine
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-end">
|
||||
<a className="navbar-item is-hidden-desktop-only"></a>
|
||||
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
<a className="navbar-link is-arrowless">
|
||||
<i className="fa-solid fa-download"></i>
|
||||
{!isEmpty(airDCPPDownloadTick) && (
|
||||
<div className="pulsating-circle"></div>
|
||||
)}
|
||||
</a>
|
||||
{!isEmpty(airDCPPDownloadTick) ? (
|
||||
<div className="navbar-dropdown is-right is-boxed">
|
||||
<a className="navbar-item">
|
||||
<DownloadProgressTick data={airDCPPDownloadTick} />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isUndefined(importJobQueue.status) &&
|
||||
location.hash !== "#/import" ? (
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
<a className="navbar-link is-arrowless">
|
||||
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
|
||||
</a>
|
||||
|
||||
<div className="navbar-dropdown is-right is-boxed">
|
||||
<a className="navbar-item">
|
||||
<ul>
|
||||
{importJobQueue.successfulJobCount > 0 ? (
|
||||
<li className="mb-2">
|
||||
<span className="tag is-success mr-2">
|
||||
{importJobQueue.successfulJobCount}
|
||||
</span>
|
||||
imported.
|
||||
</li>
|
||||
) : null}
|
||||
{importJobQueue.failedJobCount > 0 ? (
|
||||
<li>
|
||||
<span className="tag is-danger mr-2">
|
||||
{importJobQueue.failedJobCount}
|
||||
</span>
|
||||
failed to import.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AirDC++ socket connection status */}
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
{!isUndefined(airDCPPSessionInformation.user) ? (
|
||||
<>
|
||||
<a className="navbar-link is-arrowless has-text-success">
|
||||
<i className="fa-solid fa-bolt"></i>
|
||||
</a>
|
||||
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
|
||||
{/* AirDC++ Session Information */}
|
||||
|
||||
<p>
|
||||
Last login was{" "}
|
||||
<span className="tag">
|
||||
{format(
|
||||
fromUnixTime(
|
||||
airDCPPSessionInformation?.user.last_login,
|
||||
),
|
||||
"dd MMMM, yyyy",
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<hr className="navbar-divider" />
|
||||
<p>
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.user.username}
|
||||
</span>
|
||||
connected to{" "}
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.system_info.client_version}
|
||||
</span>{" "}
|
||||
with session ID{" "}
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.session_id}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a className="navbar-link is-arrowless has-text-danger">
|
||||
<i className="fa-solid fa-bolt"></i>
|
||||
</a>
|
||||
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
|
||||
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="navbar-item has-dropdown is-hoverable is-mega">
|
||||
<div className="navbar-link flex">Blog</div>
|
||||
<div id="blogDropdown" className="navbar-dropdown">
|
||||
<div className="container is-fluid">
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
|
||||
<a
|
||||
className="navbar-item "
|
||||
href="http://bulma.io/documentation/columns/basics/"
|
||||
>
|
||||
Columns
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
<a
|
||||
className="navbar-item "
|
||||
href="/documentation/overview/start/"
|
||||
>
|
||||
Overview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="navbar-divider" />
|
||||
<div className="navbar-item">
|
||||
<div className="navbar-content">
|
||||
<div className="level is-mobile">
|
||||
<div className="level-left">
|
||||
<div className="level-item">
|
||||
<strong>Stay up to date!</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-right">
|
||||
<div className="level-item">
|
||||
<a
|
||||
className="button bd-is-rss is-small"
|
||||
href="http://bulma.io/atom.xml"
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fa fa-rss"></i>
|
||||
</span>
|
||||
<span>Subscribe</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="navbar-item">
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<Link to="/settings" className="navbar-item">
|
||||
Settings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -3,22 +3,22 @@ import { Link } from "react-router-dom";
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
||||
export const Navbar2 = (): ReactElement => {
|
||||
const [colorTheme, setTheme] = useDarkMode();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [theme, setTheme] = useDarkMode();
|
||||
const darkMode = theme === "dark";
|
||||
|
||||
const toggleDarkMode = (checked) => {
|
||||
setTheme(colorTheme);
|
||||
setDarkMode(!darkMode);
|
||||
const toggleDarkMode = () => {
|
||||
setTheme(darkMode ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-900 gap-8 px-5 py-5 h-18 border-b-2 border-gray-300 dark:border-slate-200">
|
||||
{/* Logo */}
|
||||
<div className="mx-auto flex">
|
||||
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
|
||||
<header className="bg-white dark:bg-gray-900 border-b-2 border-gray-300 dark:border-slate-200">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-5">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Logo */}
|
||||
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
|
||||
|
||||
{/* Main Navigation */}
|
||||
<div className="flex flex-1 items-center justify-end md:justify-between">
|
||||
{/* Main Navigation */}
|
||||
<div className="flex flex-1 items-center justify-end md:justify-between">
|
||||
<nav
|
||||
aria-label="ThreeTwo Main Navigation"
|
||||
className="hidden md:block"
|
||||
@@ -79,48 +79,49 @@ export const Navbar2 = (): ReactElement => {
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Right-most Nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ul className="flex items-center gap-6 text-md">
|
||||
{/* Settings Icon and text */}
|
||||
<li>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center space-x-1 text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
|
||||
>
|
||||
<span className="w-5 h-5">
|
||||
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
|
||||
</span>
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
{/* Light/Dark Mode toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-600 dark:text-white">Light</span>
|
||||
<label
|
||||
htmlFor="toggle"
|
||||
className="relative inline-flex items-center"
|
||||
{/* Right-most Nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ul className="flex items-center gap-6 text-md">
|
||||
{/* Settings Icon and text */}
|
||||
<li>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center space-x-1 text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="toggle"
|
||||
className="sr-only"
|
||||
checked={darkMode}
|
||||
onChange={toggleDarkMode}
|
||||
/>
|
||||
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
|
||||
<span
|
||||
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
|
||||
darkMode ? "translate-x-4" : ""
|
||||
}`}
|
||||
></span>
|
||||
</label>
|
||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<span className="w-5 h-5">
|
||||
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
|
||||
</span>
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
{/* Light/Dark Mode toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-600 dark:text-white">Light</span>
|
||||
<label
|
||||
htmlFor="toggle"
|
||||
className="relative inline-flex items-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="toggle"
|
||||
className="sr-only"
|
||||
checked={darkMode}
|
||||
onChange={toggleDarkMode}
|
||||
/>
|
||||
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
|
||||
<span
|
||||
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
|
||||
darkMode ? "translate-x-4" : ""
|
||||
}`}
|
||||
></span>
|
||||
</label>
|
||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
45
src/client/components/shared/PopoverButton.tsx
Normal file
45
src/client/components/shared/PopoverButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from "react";
|
||||
import { useFloating, offset, flip } from "@floating-ui/react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
|
||||
|
||||
const PopoverButton = ({ content, clickHandler }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// Use destructuring to obtain the reference and floating setters, among other values.
|
||||
const { x, y, refs, strategy, floatingStyles } = useFloating({
|
||||
placement: "right",
|
||||
middleware: [offset(8), flip()],
|
||||
strategy: "absolute",
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{/* Apply the reference setter directly to the ref prop */}
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
aria-describedby="popover"
|
||||
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-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||
Mark as Wanted
|
||||
</button>
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
|
||||
style={floatingStyles}
|
||||
className="text-sm bg-slate-400 p-2 rounded-md"
|
||||
role="tooltip"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopoverButton;
|
||||
@@ -1,58 +1,89 @@
|
||||
import React, { ReactElement, useMemo, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
Row,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
useReactTable,
|
||||
PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
export const T2Table = (tableOptions): ReactElement => {
|
||||
const {
|
||||
sourceData,
|
||||
columns,
|
||||
paginationHandlers: { nextPage, previousPage },
|
||||
totalPages,
|
||||
rowClickHandler,
|
||||
} = tableOptions;
|
||||
/** Props for {@link T2Table}. */
|
||||
interface T2TableProps<TData> {
|
||||
/** Row data to render. */
|
||||
sourceData?: TData[];
|
||||
/** Total number of records across all pages, used for pagination display. */
|
||||
totalPages?: number;
|
||||
/** Column definitions (TanStack Table {@link ColumnDef} array). */
|
||||
columns?: ColumnDef<TData>[];
|
||||
/** Callbacks for navigating between pages. */
|
||||
paginationHandlers?: {
|
||||
nextPage?(pageIndex: number, pageSize: number): void;
|
||||
previousPage?(pageIndex: number, pageSize: number): void;
|
||||
};
|
||||
/** Called with the TanStack row object when a row is clicked. */
|
||||
rowClickHandler?(row: Row<TData>): void;
|
||||
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
|
||||
getRowClassName?(row: Row<TData>): string;
|
||||
/** Optional slot rendered in the toolbar area (e.g. a search input). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A paginated data table with a two-row sticky header.
|
||||
*
|
||||
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
|
||||
* element placed immediately before the table. The second header row's `top`
|
||||
* offset is measured at mount time so both rows stay flush regardless of font
|
||||
* size or padding changes.
|
||||
*/
|
||||
export const T2Table = <TData,>({
|
||||
sourceData = [],
|
||||
columns = [],
|
||||
paginationHandlers: { nextPage, previousPage } = {},
|
||||
totalPages = 0,
|
||||
rowClickHandler,
|
||||
getRowClassName,
|
||||
children,
|
||||
}: T2TableProps<TData>): ReactElement => {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [firstRowHeight, setFirstRowHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsSticky(!entry.isIntersecting),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (firstHeaderRowRef.current)
|
||||
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
|
||||
}, []);
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 1,
|
||||
pageSize: 15,
|
||||
});
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||
|
||||
/**
|
||||
* Pagination control to move forward one page
|
||||
* @returns void
|
||||
*/
|
||||
/** Advances to the next page and notifies the parent. */
|
||||
const goToNextPage = () => {
|
||||
setPagination({
|
||||
pageIndex: pageIndex + 1,
|
||||
pageSize,
|
||||
});
|
||||
nextPage(pageIndex, pageSize);
|
||||
setPagination({ pageIndex: pageIndex + 1, pageSize });
|
||||
nextPage?.(pageIndex, pageSize);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control to move backward one page
|
||||
* @returns void
|
||||
**/
|
||||
/** Goes back one page and notifies the parent. */
|
||||
const goToPreviousPage = () => {
|
||||
setPagination({
|
||||
pageIndex: pageIndex - 1,
|
||||
pageSize,
|
||||
});
|
||||
previousPage(pageIndex, pageSize);
|
||||
setPagination({ pageIndex: pageIndex - 1, pageSize });
|
||||
previousPage?.(pageIndex, pageSize);
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -61,58 +92,62 @@ export const T2Table = (tableOptions): ReactElement => {
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
pageCount: sourceData.length ?? -1,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container max-w-fit mx-14">
|
||||
<div>
|
||||
<div className="flex flex-row gap-2 justify-between mt-7">
|
||||
{/* Search bar */}
|
||||
{tableOptions.children}
|
||||
{/* pagination controls */}
|
||||
<div>
|
||||
<div className="container max-w-fit">
|
||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||
{children}
|
||||
|
||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||
<div className="mb-1">
|
||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||
<p>{totalPages} comics in all</p>
|
||||
{/* Prev/Next buttons */}
|
||||
<div className="inline-flex flex-row mt-4 mb-4">
|
||||
<button
|
||||
onClick={() => goToPreviousPage()}
|
||||
disabled={pageIndex === 1}
|
||||
className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2"
|
||||
>
|
||||
<i className="icon-[solar--arrow-left-linear] h-6 w-6"></i>
|
||||
</button>
|
||||
<button
|
||||
className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2"
|
||||
onClick={() => goToNextPage()}
|
||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
||||
>
|
||||
<i className="icon-[solar--arrow-right-linear] h-6 w-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||
{totalPages} comics in all
|
||||
</p>
|
||||
<div className="inline-flex flex-row mt-3">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={pageIndex === 1}
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
||||
>
|
||||
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||
>
|
||||
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table className="table-auto overflow-auto">
|
||||
<thead className="sticky top-0 bg-slate-200 dark:bg-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup, idx) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, idx) => (
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
||||
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className="px-3 py-3"
|
||||
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
|
||||
className={[
|
||||
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
|
||||
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
|
||||
groupIndex === 0
|
||||
? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
|
||||
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
|
||||
].join(' ')}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -120,37 +155,23 @@ export const T2Table = (tableOptions): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, idx) => {
|
||||
return (
|
||||
<tr key={row.id} onClick={() => rowClickHandler(row)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td key={cell.id} className="align-top">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => rowClickHandler?.(row)}
|
||||
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2 align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
T2Table.propTypes = {
|
||||
sourceData: PropTypes.array,
|
||||
totalPages: PropTypes.number,
|
||||
columns: PropTypes.array,
|
||||
paginationHandlers: PropTypes.shape({
|
||||
nextPage: PropTypes.func,
|
||||
previousPage: PropTypes.func,
|
||||
}),
|
||||
rowClickHandler: PropTypes.func,
|
||||
children: PropTypes.any,
|
||||
};
|
||||
export default T2Table;
|
||||
|
||||
@@ -104,3 +104,10 @@ export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
|
||||
port: "3000",
|
||||
apiPath: `/api/torrentjobs`,
|
||||
});
|
||||
|
||||
export const AIRDCPP_SERVICE_BASE_URI = hostURIBuilder({
|
||||
protocol: "http",
|
||||
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||
port: "3000",
|
||||
apiPath: `/api/airdcpp`,
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"displayName": "Dashboard"
|
||||
},
|
||||
{
|
||||
"id": "gen-gls",
|
||||
"displayName": "Global Search"
|
||||
"id": "gen-docker-vars",
|
||||
"displayName": "Docker ENV vars"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import React, { createContext } from "react";
|
||||
|
||||
export const SocketIOContext = createContext({});
|
||||
|
||||
export const SocketIOProvider = ({ children, socket }) => {
|
||||
return (
|
||||
<SocketIOContext.Provider value={socket}>
|
||||
{children}
|
||||
</SocketIOContext.Provider>
|
||||
);
|
||||
};
|
||||
76
src/client/graphql/adapters/comicAdapter.ts
Normal file
76
src/client/graphql/adapters/comicAdapter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { GetComicByIdQuery } from '../generated';
|
||||
|
||||
/**
|
||||
* Adapter to transform GraphQL Comic response to legacy REST API format
|
||||
* This allows gradual migration while maintaining compatibility with existing components
|
||||
*/
|
||||
export function adaptGraphQLComicToLegacy(graphqlComic: GetComicByIdQuery['comic']) {
|
||||
if (!graphqlComic) return null;
|
||||
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
const comicvine = graphqlComic.sourcedMetadata?.comicvine
|
||||
? (typeof graphqlComic.sourcedMetadata.comicvine === 'string'
|
||||
? JSON.parse(graphqlComic.sourcedMetadata.comicvine)
|
||||
: graphqlComic.sourcedMetadata.comicvine)
|
||||
: undefined;
|
||||
|
||||
const comicInfo = graphqlComic.sourcedMetadata?.comicInfo
|
||||
? (typeof graphqlComic.sourcedMetadata.comicInfo === 'string'
|
||||
? JSON.parse(graphqlComic.sourcedMetadata.comicInfo)
|
||||
: graphqlComic.sourcedMetadata.comicInfo)
|
||||
: undefined;
|
||||
|
||||
const locg = graphqlComic.sourcedMetadata?.locg || undefined;
|
||||
|
||||
// Use inferredMetadata from GraphQL response, or build from canonical metadata as fallback
|
||||
const inferredMetadata = graphqlComic.inferredMetadata || {
|
||||
issue: {
|
||||
name: graphqlComic.canonicalMetadata?.title?.value ||
|
||||
graphqlComic.canonicalMetadata?.series?.value ||
|
||||
graphqlComic.rawFileDetails?.name || '',
|
||||
number: graphqlComic.canonicalMetadata?.issueNumber?.value
|
||||
? parseInt(graphqlComic.canonicalMetadata.issueNumber.value, 10)
|
||||
: undefined,
|
||||
year: graphqlComic.canonicalMetadata?.publicationDate?.value?.substring(0, 4) ||
|
||||
graphqlComic.canonicalMetadata?.coverDate?.value?.substring(0, 4),
|
||||
subtitle: graphqlComic.canonicalMetadata?.series?.value,
|
||||
},
|
||||
};
|
||||
|
||||
// Build acquisition data (if available from importStatus or other fields)
|
||||
const acquisition = {
|
||||
directconnect: {
|
||||
downloads: [],
|
||||
},
|
||||
torrent: [],
|
||||
};
|
||||
|
||||
// Transform rawFileDetails to match expected format
|
||||
const rawFileDetails = graphqlComic.rawFileDetails ? {
|
||||
name: graphqlComic.rawFileDetails.name || '',
|
||||
filePath: graphqlComic.rawFileDetails.filePath,
|
||||
fileSize: graphqlComic.rawFileDetails.fileSize,
|
||||
extension: graphqlComic.rawFileDetails.extension,
|
||||
mimeType: graphqlComic.rawFileDetails.mimeType,
|
||||
containedIn: graphqlComic.rawFileDetails.containedIn,
|
||||
pageCount: graphqlComic.rawFileDetails.pageCount,
|
||||
archive: graphqlComic.rawFileDetails.archive,
|
||||
cover: graphqlComic.rawFileDetails.cover,
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
_id: graphqlComic.id,
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
sourcedMetadata: {
|
||||
comicvine,
|
||||
locg,
|
||||
comicInfo,
|
||||
},
|
||||
acquisition,
|
||||
createdAt: graphqlComic.createdAt || new Date().toISOString(),
|
||||
updatedAt: graphqlComic.updatedAt || new Date().toISOString(),
|
||||
// Include the full GraphQL data for components that can use it
|
||||
__graphql: graphqlComic,
|
||||
} as any; // Use 'as any' to bypass strict type checking during migration
|
||||
}
|
||||
43
src/client/graphql/fetcher.ts
Normal file
43
src/client/graphql/fetcher.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LIBRARY_SERVICE_HOST } from '../constants/endpoints';
|
||||
|
||||
export function fetcher<TData, TVariables>(
|
||||
query: string,
|
||||
variables?: TVariables,
|
||||
options?: RequestInit['headers']
|
||||
) {
|
||||
return async (): Promise<TData> => {
|
||||
try {
|
||||
const res = await fetch(`${LIBRARY_SERVICE_HOST}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
|
||||
// Check if the response is OK (status 200-299)
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.errors) {
|
||||
const { message } = json.errors[0] || {};
|
||||
throw new Error(message || 'Error fetching data');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
} catch (error) {
|
||||
// Handle network errors or other fetch failures
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to fetch: ${error.message}`);
|
||||
}
|
||||
throw new Error('Failed to fetch data from server');
|
||||
}
|
||||
};
|
||||
}
|
||||
2489
src/client/graphql/generated.ts
Normal file
2489
src/client/graphql/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
179
src/client/graphql/queries/comicDetail.graphql
Normal file
179
src/client/graphql/queries/comicDetail.graphql
Normal file
@@ -0,0 +1,179 @@
|
||||
fragment ProvenanceFull on Provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
|
||||
fragment MetadataFieldFull on MetadataField {
|
||||
value
|
||||
provenance { ...ProvenanceFull }
|
||||
userOverride
|
||||
}
|
||||
|
||||
query GetComicById($id: ID!) {
|
||||
comic(id: $id) {
|
||||
id
|
||||
|
||||
# Inferred metadata
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
|
||||
# Canonical metadata
|
||||
canonicalMetadata {
|
||||
# ── Identity ──────────────────────────────────────────────────────────
|
||||
title { ...MetadataFieldFull }
|
||||
series { ...MetadataFieldFull }
|
||||
volume { ...MetadataFieldFull }
|
||||
issueNumber { ...MetadataFieldFull }
|
||||
collectionTitle { ...MetadataFieldFull }
|
||||
|
||||
# ── Series ────────────────────────────────────────────────────────────
|
||||
seriesInfo {
|
||||
issueCount
|
||||
startYear
|
||||
volumeCount
|
||||
sortName
|
||||
language
|
||||
alternativeNames { ...MetadataFieldFull }
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
|
||||
# ── Publication ───────────────────────────────────────────────────────
|
||||
publisher { ...MetadataFieldFull }
|
||||
imprint { ...MetadataFieldFull }
|
||||
coverDate { ...MetadataFieldFull }
|
||||
storeDate { ...MetadataFieldFull }
|
||||
publicationDate { ...MetadataFieldFull }
|
||||
language { ...MetadataFieldFull }
|
||||
|
||||
# ── Content ───────────────────────────────────────────────────────────
|
||||
description { ...MetadataFieldFull }
|
||||
notes { ...MetadataFieldFull }
|
||||
stories { ...MetadataFieldFull }
|
||||
storyArcs {
|
||||
name
|
||||
number
|
||||
id
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
characters { ...MetadataFieldFull }
|
||||
teams { ...MetadataFieldFull }
|
||||
locations { ...MetadataFieldFull }
|
||||
universes {
|
||||
name
|
||||
designation
|
||||
id
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
coverImage { ...MetadataFieldFull }
|
||||
|
||||
# ── Credits ───────────────────────────────────────────────────────────
|
||||
creators {
|
||||
name
|
||||
role
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
|
||||
# ── Classification ────────────────────────────────────────────────────
|
||||
genres { ...MetadataFieldFull }
|
||||
tags { ...MetadataFieldFull }
|
||||
ageRating { ...MetadataFieldFull }
|
||||
|
||||
# ── Physical ──────────────────────────────────────────────────────────
|
||||
pageCount { ...MetadataFieldFull }
|
||||
format { ...MetadataFieldFull }
|
||||
|
||||
# ── Commercial ────────────────────────────────────────────────────────
|
||||
prices {
|
||||
amount
|
||||
currency
|
||||
country
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
gtin {
|
||||
isbn
|
||||
upc
|
||||
userOverride
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
reprints {
|
||||
description
|
||||
id
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
communityRating { ...MetadataFieldFull }
|
||||
|
||||
# ── External ──────────────────────────────────────────────────────────
|
||||
externalIDs {
|
||||
source
|
||||
externalId
|
||||
primary
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
urls {
|
||||
url
|
||||
primary
|
||||
provenance { ...ProvenanceFull }
|
||||
}
|
||||
}
|
||||
|
||||
# Sourced metadata
|
||||
sourcedMetadata {
|
||||
comicInfo
|
||||
comicvine
|
||||
metron
|
||||
gcd
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
url
|
||||
cover
|
||||
description
|
||||
price
|
||||
rating
|
||||
pulls
|
||||
potw
|
||||
}
|
||||
}
|
||||
|
||||
# Raw file details
|
||||
rawFileDetails {
|
||||
name
|
||||
filePath
|
||||
fileSize
|
||||
extension
|
||||
mimeType
|
||||
containedIn
|
||||
pageCount
|
||||
archive {
|
||||
uncompressed
|
||||
expandedPath
|
||||
}
|
||||
cover {
|
||||
filePath
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
# Import status
|
||||
importStatus {
|
||||
isImported
|
||||
tagged
|
||||
matchedResult {
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamps
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
222
src/client/graphql/queries/dashboard.graphql
Normal file
222
src/client/graphql/queries/dashboard.graphql
Normal file
@@ -0,0 +1,222 @@
|
||||
query GetComics($page: Int, $limit: Int, $search: String, $publisher: String, $series: String) {
|
||||
comics(page: $page, limit: $limit, search: $search, publisher: $publisher, series: $series) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
currentPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetRecentComics($limit: Int) {
|
||||
comics(page: 1, limit: $limit) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
publisher {
|
||||
value
|
||||
}
|
||||
}
|
||||
importStatus {
|
||||
isRawFileMissing
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
query GetWantedComics($paginationOptions: PaginationOptionsInput!, $predicate: PredicateInput) {
|
||||
getComicBooks(paginationOptions: $paginationOptions, predicate: $predicate) {
|
||||
docs {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalDocs
|
||||
limit
|
||||
page
|
||||
totalPages
|
||||
hasNextPage
|
||||
hasPrevPage
|
||||
}
|
||||
}
|
||||
|
||||
query GetVolumeGroups {
|
||||
getComicBookGroups {
|
||||
id
|
||||
volumes {
|
||||
id
|
||||
name
|
||||
count_of_issues
|
||||
publisher {
|
||||
id
|
||||
name
|
||||
}
|
||||
start_year
|
||||
image {
|
||||
small_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetLibraryStatistics {
|
||||
getLibraryStatistics {
|
||||
totalDocuments
|
||||
comicsMissingFiles
|
||||
comicDirectorySize {
|
||||
fileCount
|
||||
totalSizeInGB
|
||||
}
|
||||
statistics {
|
||||
fileTypes {
|
||||
id
|
||||
data
|
||||
}
|
||||
issues {
|
||||
id {
|
||||
id
|
||||
name
|
||||
}
|
||||
data
|
||||
}
|
||||
fileLessComics {
|
||||
id
|
||||
}
|
||||
issuesWithComicInfoXML {
|
||||
id
|
||||
}
|
||||
publisherWithMostComicsInLibrary {
|
||||
id
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetWeeklyPullList($input: WeeklyPullListInput!) {
|
||||
getWeeklyPullList(input: $input) {
|
||||
result {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
src/client/graphql/queries/import.graphql
Normal file
61
src/client/graphql/queries/import.graphql
Normal file
@@ -0,0 +1,61 @@
|
||||
query GetImportStatistics($directoryPath: String) {
|
||||
getImportStatistics(directoryPath: $directoryPath) {
|
||||
success
|
||||
directory
|
||||
stats {
|
||||
totalLocalFiles
|
||||
alreadyImported
|
||||
newFiles
|
||||
missingFiles
|
||||
percentageImported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation StartNewImport($sessionId: String!) {
|
||||
startNewImport(sessionId: $sessionId) {
|
||||
success
|
||||
message
|
||||
jobsQueued
|
||||
}
|
||||
}
|
||||
|
||||
mutation StartIncrementalImport($sessionId: String!, $directoryPath: String) {
|
||||
startIncrementalImport(sessionId: $sessionId, directoryPath: $directoryPath) {
|
||||
success
|
||||
message
|
||||
stats {
|
||||
total
|
||||
alreadyImported
|
||||
newFiles
|
||||
queued
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetJobResultStatistics {
|
||||
getJobResultStatistics {
|
||||
sessionId
|
||||
earliestTimestamp
|
||||
completedJobs
|
||||
failedJobs
|
||||
}
|
||||
}
|
||||
|
||||
query GetActiveImportSession {
|
||||
getActiveImportSession {
|
||||
sessionId
|
||||
type
|
||||
status
|
||||
startedAt
|
||||
completedAt
|
||||
directoryPath
|
||||
stats {
|
||||
totalFiles
|
||||
filesQueued
|
||||
filesProcessed
|
||||
filesSucceeded
|
||||
filesFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/client/graphql/queries/library.graphql
Normal file
72
src/client/graphql/queries/library.graphql
Normal file
@@ -0,0 +1,72 @@
|
||||
# Library queries
|
||||
# Note: The Library component currently uses Elasticsearch for search functionality
|
||||
# These queries are prepared for when the backend supports GraphQL-based library queries
|
||||
|
||||
query GetLibraryComics($page: Int, $limit: Int, $search: String, $series: String) {
|
||||
comics(page: $page, limit: $limit, search: $search, series: $series) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
filePath
|
||||
fileSize
|
||||
extension
|
||||
mimeType
|
||||
pageCount
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
publisher {
|
||||
value
|
||||
}
|
||||
pageCount {
|
||||
value
|
||||
}
|
||||
}
|
||||
importStatus {
|
||||
isImported
|
||||
tagged
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
currentPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import BlazeSlider from "blaze-slider";
|
||||
|
||||
export const useBlazeSlider = (config) => {
|
||||
const sliderRef = React.useRef();
|
||||
const elRef = React.useRef();
|
||||
|
||||
useEffect(() => {
|
||||
// if not already initialized
|
||||
if (!sliderRef.current) {
|
||||
sliderRef.current = new BlazeSlider(elRef.current, config);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return elRef;
|
||||
};
|
||||
@@ -13,5 +13,5 @@ export const useDarkMode = () => {
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme, colorTheme]);
|
||||
|
||||
return [colorTheme, setTheme];
|
||||
return [theme, setTheme];
|
||||
};
|
||||
|
||||
291
src/client/hooks/useImportSessionStatus.ts
Normal file
291
src/client/hooks/useImportSessionStatus.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useGetActiveImportSessionQuery } from "../graphql/generated";
|
||||
import { useStore } from "../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export type ImportSessionStatus =
|
||||
| "idle" // No import in progress
|
||||
| "running" // Import actively processing
|
||||
| "completed" // Import finished successfully
|
||||
| "failed" // Import finished with errors
|
||||
| "unknown"; // Unable to determine status
|
||||
|
||||
export interface ImportSessionState {
|
||||
status: ImportSessionStatus;
|
||||
sessionId: string | null;
|
||||
progress: number; // 0-100
|
||||
stats: {
|
||||
filesQueued: number;
|
||||
filesProcessed: number;
|
||||
filesSucceeded: number;
|
||||
filesFailed: number;
|
||||
} | null;
|
||||
isComplete: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to definitively determine import session completion status
|
||||
*
|
||||
* Uses Socket.IO events to trigger GraphQL refetches:
|
||||
* - IMPORT_SESSION_STARTED: New import started
|
||||
* - IMPORT_SESSION_UPDATED: Progress update
|
||||
* - IMPORT_SESSION_COMPLETED: Import finished
|
||||
* - LS_IMPORT_QUEUE_DRAINED: All jobs processed
|
||||
*
|
||||
* A session is considered DEFINITIVELY COMPLETE when:
|
||||
* - session.status === "completed" OR session.status === "failed"
|
||||
* - OR LS_IMPORT_QUEUE_DRAINED event is received AND no active session exists
|
||||
* - OR IMPORT_SESSION_COMPLETED event is received
|
||||
*
|
||||
* NO POLLING - relies entirely on Socket.IO events for real-time updates
|
||||
*/
|
||||
export const useImportSessionStatus = (): ImportSessionState => {
|
||||
|
||||
const [sessionState, setSessionState] = useState<ImportSessionState>({
|
||||
status: "unknown",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const { getSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
getSocket: state.getSocket,
|
||||
}))
|
||||
);
|
||||
|
||||
// Track if we've received completion events
|
||||
const completionEventReceived = useRef(false);
|
||||
const queueDrainedEventReceived = useRef(false);
|
||||
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
|
||||
// Prevents a stale "running" DB session from showing as active on hard refresh.
|
||||
const sessionStartedEventReceived = useRef(false);
|
||||
|
||||
// Query active import session - polls every 3s as a fallback when a session is
|
||||
// active (e.g. tab re-opened mid-import and socket events were missed)
|
||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (query) => {
|
||||
const s = (query.state.data as any)?.getActiveImportSession;
|
||||
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const session = sessionData?.getActiveImportSession;
|
||||
|
||||
/**
|
||||
* Determine definitive completion status
|
||||
*/
|
||||
const determineStatus = useCallback((): ImportSessionState => {
|
||||
// Case 1: No session exists
|
||||
if (!session) {
|
||||
// If we received completion events, mark as completed
|
||||
if (completionEventReceived.current || queueDrainedEventReceived.current) {
|
||||
return {
|
||||
status: "completed",
|
||||
sessionId: null,
|
||||
progress: 100,
|
||||
stats: null,
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, system is idle
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Session exists - check its status field
|
||||
const { status, sessionId, stats } = session;
|
||||
|
||||
// Calculate progress
|
||||
const progress = stats.filesQueued > 0
|
||||
? Math.min(100, Math.round((stats.filesSucceeded / stats.filesQueued) * 100))
|
||||
: 0;
|
||||
|
||||
// Detect stuck sessions: status="running" but all files processed
|
||||
const isStuckOrComplete =
|
||||
status === "running" &&
|
||||
stats.filesQueued > 0 &&
|
||||
stats.filesProcessed >= stats.filesQueued;
|
||||
|
||||
// DEFINITIVE COMPLETION: Check status field or stuck session
|
||||
if (status === "completed" || isStuckOrComplete) {
|
||||
completionEventReceived.current = true;
|
||||
return {
|
||||
status: "completed",
|
||||
sessionId,
|
||||
progress: 100,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
completionEventReceived.current = true;
|
||||
return {
|
||||
status: "failed",
|
||||
sessionId,
|
||||
progress,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Check if session is actually running/active
|
||||
if (status === "running" || status === "active" || status === "processing") {
|
||||
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.
|
||||
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
||||
|
||||
// No in-session event AND no actual progress → stale unclosed session from a previous run.
|
||||
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
|
||||
const likelyStale = !sessionStartedEventReceived.current
|
||||
&& stats.filesProcessed === 0
|
||||
&& stats.filesSucceeded === 0;
|
||||
|
||||
if ((hasQueuedWork || justStarted) && !likelyStale) {
|
||||
return {
|
||||
status: "running",
|
||||
sessionId,
|
||||
progress,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: false,
|
||||
isActive: true,
|
||||
};
|
||||
} else {
|
||||
// Session says "running" but all files processed — likely a stale session
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: Session exists but has unknown/other status - treat as idle
|
||||
console.warn(`[useImportSessionStatus] Unknown session status: "${status}"`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
/**
|
||||
* Initial fetch on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.log("[useImportSessionStatus] Initial mount - fetching session");
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
/**
|
||||
* Update state whenever session data changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
const newState = determineStatus();
|
||||
setSessionState(newState);
|
||||
|
||||
// Log status changes for debugging
|
||||
console.log("[useImportSessionStatus] Status determined:", {
|
||||
status: newState.status,
|
||||
sessionId: newState.sessionId,
|
||||
progress: newState.progress,
|
||||
isComplete: newState.isComplete,
|
||||
isActive: newState.isActive,
|
||||
stats: newState.stats,
|
||||
rawSession: session,
|
||||
});
|
||||
}, [determineStatus, session]);
|
||||
|
||||
/**
|
||||
* Listen to Socket.IO events for real-time completion detection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleSessionCompleted = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_COMPLETED event received");
|
||||
completionEventReceived.current = true;
|
||||
refetch(); // Immediately refetch to get final status
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[useImportSessionStatus] LS_IMPORT_QUEUE_DRAINED event received");
|
||||
queueDrainedEventReceived.current = true;
|
||||
refetch(); // Immediately refetch to confirm no active session
|
||||
};
|
||||
|
||||
const handleSessionStarted = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED / LS_INCREMENTAL_IMPORT_STARTED event received");
|
||||
// Reset completion flags when new session starts
|
||||
completionEventReceived.current = false;
|
||||
queueDrainedEventReceived.current = false;
|
||||
sessionStartedEventReceived.current = true;
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleSessionUpdated = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_UPDATED event received");
|
||||
refetch();
|
||||
};
|
||||
|
||||
// Listen to completion events
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
};
|
||||
}, [getSocket, refetch]);
|
||||
|
||||
return sessionState;
|
||||
};
|
||||
@@ -1,22 +1,30 @@
|
||||
import React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
import App from "./components/App";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import Settings from "./components/Settings/Settings";
|
||||
import { ErrorPage } from "./components/shared/ErrorPage";
|
||||
const rootEl = document.getElementById("root");
|
||||
const root = createRoot(rootEl);
|
||||
import "./shared/utils/i18n.util";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Import from "./components/Import/Import";
|
||||
import Dashboard from "./components/Dashboard/Dashboard";
|
||||
import Search from "./components/Search/Search";
|
||||
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
|
||||
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
|
||||
import Volumes from "./components/Volumes/Volumes";
|
||||
import WantedComics from "./components/WantedComics/WantedComics";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const Settings = lazy(() => import("./components/Settings/Settings"));
|
||||
const Import = lazy(() => import("./components/Import/Import"));
|
||||
const Dashboard = lazy(() => import("./components/Dashboard/Dashboard"));
|
||||
const Search = lazy(() => import("./components/Search/Search"));
|
||||
const TabulatedContentContainer = lazy(() => import("./components/Library/TabulatedContentContainer"));
|
||||
const ComicDetailContainer = lazy(() => import("./components/ComicDetail/ComicDetailContainer").then(m => ({ default: m.ComicDetailContainer })));
|
||||
const Volumes = lazy(() => import("./components/Volumes/Volumes"));
|
||||
const VolumeDetails = lazy(() => import("./components/VolumeDetail/VolumeDetail"));
|
||||
const WantedComics = lazy(() => import("./components/WantedComics/WantedComics"));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -37,6 +45,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{ path: "import", element: <Import path={"./comics"} /> },
|
||||
{ path: "search", element: <Search /> },
|
||||
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
|
||||
{ path: "volumes", element: <Volumes /> },
|
||||
{ path: "wanted", element: <WantedComics /> },
|
||||
],
|
||||
@@ -45,6 +54,8 @@ const router = createBrowserRouter([
|
||||
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Suspense>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
4
src/client/locales/en/translation.json
Normal file
4
src/client/locales/en/translation.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"issueWithCount_one": "{{count}} issue",
|
||||
"issueWithCount_other": "{{count}} issues"
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
AIRDCPP_SEARCH_IN_PROGRESS,
|
||||
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||
AIRDCPP_HUB_SEARCHES_SENT,
|
||||
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
|
||||
AIRDCPP_BUNDLES_FETCHED,
|
||||
AIRDCPP_TRANSFERS_FETCHED,
|
||||
LIBRARY_ISSUE_BUNDLES,
|
||||
AIRDCPP_SOCKET_CONNECTED,
|
||||
AIRDCPP_SOCKET_DISCONNECTED,
|
||||
} from "../constants/action-types";
|
||||
import { LOCATION_CHANGE } from "redux-first-history";
|
||||
import { isNil, isUndefined } from "lodash";
|
||||
import { difference } from "../shared/utils/object.utils";
|
||||
|
||||
const initialState = {
|
||||
searchResults: [],
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: null,
|
||||
searchInstance: null,
|
||||
downloadResult: null,
|
||||
bundleDBImportResult: null,
|
||||
downloadFileStatus: {},
|
||||
bundles: [],
|
||||
transfers: [],
|
||||
isAirDCPPSocketConnected: false,
|
||||
airDCPPSessionInfo: {},
|
||||
socketDisconnectionReason: {},
|
||||
};
|
||||
|
||||
function airdcppReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case AIRDCPP_SEARCH_RESULTS_ADDED:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [...state.searchResults, action.groupedResult],
|
||||
isAirDCPPSearchInProgress: true,
|
||||
};
|
||||
case AIRDCPP_SEARCH_RESULTS_UPDATED:
|
||||
const bundleToUpdateIndex = state.searchResults.findIndex(
|
||||
(bundle) => bundle.result.id === action.groupedResult.result.id,
|
||||
);
|
||||
const updatedState = [...state.searchResults];
|
||||
|
||||
if (
|
||||
!isNil(
|
||||
difference(updatedState[bundleToUpdateIndex], action.groupedResult),
|
||||
)
|
||||
) {
|
||||
updatedState[bundleToUpdateIndex] = action.groupedResult;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
searchResults: updatedState,
|
||||
};
|
||||
case AIRDCPP_SEARCH_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSearchInProgress: true,
|
||||
};
|
||||
case AIRDCPP_HUB_SEARCHES_SENT:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: action.searchInfo,
|
||||
searchInstance: action.instance,
|
||||
};
|
||||
case AIRDCPP_RESULT_DOWNLOAD_INITIATED:
|
||||
return {
|
||||
...state,
|
||||
downloadResult: action.downloadResult,
|
||||
bundleDBImportResult: action.bundleDBImportResult,
|
||||
};
|
||||
case AIRDCPP_DOWNLOAD_PROGRESS_TICK:
|
||||
return {
|
||||
...state,
|
||||
downloadProgressData: action.downloadProgressData,
|
||||
};
|
||||
case AIRDCPP_BUNDLES_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
bundles: action.bundles,
|
||||
};
|
||||
case LIBRARY_ISSUE_BUNDLES:
|
||||
return {
|
||||
...state,
|
||||
issue_bundles: action.issue_bundles,
|
||||
};
|
||||
case AIRDCPP_FILE_DOWNLOAD_COMPLETED:
|
||||
console.log("COMPLETED", action);
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case AIRDCPP_TRANSFERS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
transfers: action.bundles,
|
||||
};
|
||||
|
||||
case AIRDCPP_SOCKET_CONNECTED:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSocketConnected: true,
|
||||
airDCPPSessionInfo: action.data,
|
||||
};
|
||||
|
||||
case AIRDCPP_SOCKET_DISCONNECTED:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSocketConnected: false,
|
||||
socketDisconnectionReason: action.data,
|
||||
};
|
||||
case LOCATION_CHANGE:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: null,
|
||||
searchInstance: null,
|
||||
downloadResult: null,
|
||||
bundleDBImportResult: null,
|
||||
// bundles: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default airdcppReducer;
|
||||
@@ -1,138 +0,0 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import {
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_CLEANUP,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
CV_WEEKLY_PULLLIST_FETCHED,
|
||||
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||
LIBRARY_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
|
||||
const initialState = {
|
||||
pullList: [],
|
||||
libraryStatistics: [],
|
||||
searchResults: [],
|
||||
searchQuery: {},
|
||||
inProgress: false,
|
||||
comicBookDetail: {},
|
||||
comicBooksDetails: [],
|
||||
issuesForVolume: [],
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
|
||||
function comicinfoReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case CV_API_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
inProgress: true,
|
||||
};
|
||||
case CV_SEARCH_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
searchResults: action.searchResults,
|
||||
searchQuery: action.searchQueryObject,
|
||||
inProgress: false,
|
||||
};
|
||||
case IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
IMS_inProgress: true,
|
||||
};
|
||||
|
||||
case IMS_COMIC_BOOK_DB_OBJECT_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBookDetail: action.comicBookDetail,
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
case IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBooksDetails: action.comicBooks,
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
case CV_CLEANUP:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
searchQuery: {},
|
||||
issuesForVolume: [],
|
||||
};
|
||||
case CV_ISSUES_METADATA_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
|
||||
case CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
issuesForVolume: action.issues,
|
||||
inProgress: false,
|
||||
};
|
||||
|
||||
case CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED:
|
||||
const updatedState = [...state.issuesForVolume];
|
||||
action.matches.map((match) => {
|
||||
updatedState.map((issue, idx) => {
|
||||
const matches = [];
|
||||
if (!isEmpty(match.hits.hits)) {
|
||||
return match.hits.hits.map((hit) => {
|
||||
if (
|
||||
parseInt(issue.issue_number, 10) ===
|
||||
hit._source.inferredMetadata.issue.number
|
||||
) {
|
||||
matches.push(hit);
|
||||
const updatedIssueResult = { ...issue, matches };
|
||||
updatedState[idx] = updatedIssueResult;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
issuesForVolume: updatedState,
|
||||
};
|
||||
|
||||
case CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
case CV_WEEKLY_PULLLIST_FETCHED: {
|
||||
const foo = [];
|
||||
action.data.map((item) => {
|
||||
foo.push({issue: item})
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
inProgress: false,
|
||||
pullList: foo,
|
||||
};
|
||||
}
|
||||
case LIBRARY_STATISTICS_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
case LIBRARY_STATISTICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
inProgress: false,
|
||||
libraryStatistics: action.data,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default comicinfoReducer;
|
||||
@@ -1,334 +0,0 @@
|
||||
import { isUndefined, map } from "lodash";
|
||||
import { LOCATION_CHANGE } from "redux-first-history";
|
||||
import { determineCoverFile } from "../shared/utils/metadata.utils";
|
||||
import {
|
||||
IMS_COMICBOOK_METADATA_FETCHED,
|
||||
IMS_RAW_IMPORT_SUCCESSFUL,
|
||||
IMS_RAW_IMPORT_FAILED,
|
||||
IMS_RECENT_COMICS_FETCHED,
|
||||
IMS_WANTED_COMICS_FETCHED,
|
||||
WANTED_COMICS_FETCHED,
|
||||
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||
IMS_CV_METADATA_IMPORT_FAILED,
|
||||
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
LS_IMPORT,
|
||||
LS_COVER_EXTRACTED,
|
||||
LS_COVER_EXTRACTION_FAILED,
|
||||
LS_COMIC_ADDED,
|
||||
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||
SS_SEARCH_RESULTS_FETCHED,
|
||||
SS_SEARCH_IN_PROGRESS,
|
||||
FILEOPS_STATE_RESET,
|
||||
LS_IMPORT_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_FAILED,
|
||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
VOLUMES_FETCHED,
|
||||
COMICBOOK_EXTRACTION_SUCCESS,
|
||||
LIBRARY_SERVICE_HEALTH,
|
||||
LS_IMPORT_QUEUE_DRAINED,
|
||||
LS_SET_QUEUE_STATUS,
|
||||
RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
|
||||
const initialState = {
|
||||
IMSCallInProgress: false,
|
||||
IMGCallInProgress: false,
|
||||
SSCallInProgress: false,
|
||||
imageAnalysisResults: {},
|
||||
comicBookExtractionInProgress: false,
|
||||
LSQueueImportStatus: undefined,
|
||||
comicBookMetadata: [],
|
||||
comicVolumeGroups: [],
|
||||
isSocketConnected: false,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportError: {},
|
||||
rawImportError: {},
|
||||
extractedComicBookArchive: {
|
||||
reading: [],
|
||||
analysis: [],
|
||||
},
|
||||
recentComics: [],
|
||||
wantedComics: [],
|
||||
libraryComics: [],
|
||||
volumes: [],
|
||||
librarySearchResultsFormatted: [],
|
||||
lastQueueJob: "",
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
importJobStatistics: [],
|
||||
libraryQueueResults: [],
|
||||
librarySearchError: {},
|
||||
libraryServiceStatus: {},
|
||||
};
|
||||
function fileOpsReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case IMS_COMICBOOK_METADATA_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBookMetadata: [...state.comicBookMetadata, action.data],
|
||||
IMSCallInProgress: false,
|
||||
};
|
||||
|
||||
case LS_IMPORT_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMS_RAW_IMPORT_SUCCESSFUL:
|
||||
return {
|
||||
...state,
|
||||
rawImportDetails: action.rawImportDetails,
|
||||
};
|
||||
case IMS_RAW_IMPORT_FAILED:
|
||||
return {
|
||||
...state,
|
||||
rawImportErorr: action.rawImportError,
|
||||
};
|
||||
case IMS_RECENT_COMICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
recentComics: action.data.docs,
|
||||
};
|
||||
case IMS_WANTED_COMICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
wantedComics: action.data,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_SUCCESSFUL:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportDetails: action.importResult,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: true,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_FAILED:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportError: action.importError,
|
||||
};
|
||||
case IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_GROUPS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
comicVolumeGroups: action.data,
|
||||
IMSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_GROUPS_CALL_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: false,
|
||||
error: action.error,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
comicBookExtractionInProgress: true,
|
||||
};
|
||||
}
|
||||
|
||||
case LOCATION_CHANGE: {
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: [],
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT: {
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: "running",
|
||||
};
|
||||
}
|
||||
|
||||
case LS_COVER_EXTRACTED: {
|
||||
if (state.recentComics.length === 5) {
|
||||
state.recentComics.pop();
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
successfulJobCount: action.completedJobCount,
|
||||
lastQueueJob: action.importResult.rawFileDetails.name,
|
||||
recentComics: [...state.recentComics, action.importResult],
|
||||
};
|
||||
}
|
||||
|
||||
case LS_COVER_EXTRACTION_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
failedJobCount: action.failedJobCount,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT_QUEUE_DRAINED: {
|
||||
localStorage.removeItem("sessionId");
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: "drained",
|
||||
};
|
||||
}
|
||||
|
||||
case RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION: {
|
||||
console.log("Restoring state for an active import in progress...");
|
||||
return {
|
||||
...state,
|
||||
successfulJobCount: action.completedJobCount,
|
||||
failedJobCount: action.failedJobCount,
|
||||
LSQueueImportStatus: action.queueStatus,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_SET_QUEUE_STATUS: {
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: action.data.queueStatus,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT_JOB_STATISTICS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
importJobStatistics: action.data,
|
||||
};
|
||||
}
|
||||
|
||||
case COMICBOOK_EXTRACTION_SUCCESS: {
|
||||
const comicBookPages: string[] = [];
|
||||
map(action.result.files, (page) => {
|
||||
const pageFilePath = removeLeadingPeriod(page);
|
||||
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
|
||||
comicBookPages.push(imagePath);
|
||||
});
|
||||
|
||||
switch (action.result.purpose) {
|
||||
case "reading":
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: {
|
||||
reading: comicBookPages,
|
||||
},
|
||||
comicBookExtractionInProgress: false,
|
||||
};
|
||||
|
||||
case "analysis":
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: {
|
||||
analysis: comicBookPages,
|
||||
},
|
||||
comicBookExtractionInProgress: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case LS_COMIC_ADDED: {
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
}
|
||||
case IMG_ANALYSIS_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMGCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMG_ANALYSIS_DATA_FETCH_SUCCESS: {
|
||||
return {
|
||||
...state,
|
||||
imageAnalysisResults: action.result,
|
||||
};
|
||||
}
|
||||
|
||||
case SS_SEARCH_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
SSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
|
||||
case SS_SEARCH_RESULTS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
libraryComics: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case SS_SEARCH_RESULTS_FETCHED_SPECIAL: {
|
||||
const foo = [];
|
||||
if (!isUndefined(action.data.hits)) {
|
||||
map(action.data.hits, ({ _source }) => {
|
||||
foo.push(_source);
|
||||
});
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
librarySearchResultsFormatted: foo,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case WANTED_COMICS_FETCHED: {
|
||||
const foo = [];
|
||||
if (!isUndefined(action.data.hits)) {
|
||||
map(action.data.hits, ({ _source }) => {
|
||||
foo.push(_source);
|
||||
});
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
wantedComics: foo,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
|
||||
case VOLUMES_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
volumes: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
|
||||
case SS_SEARCH_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
librarySearchError: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case LIBRARY_SERVICE_HEALTH: {
|
||||
return {
|
||||
...state,
|
||||
libraryServiceStatus: action.status,
|
||||
};
|
||||
}
|
||||
case FILEOPS_STATE_RESET: {
|
||||
return {
|
||||
...state,
|
||||
imageAnalysisResults: {},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default fileOpsReducer;
|
||||
@@ -1,11 +0,0 @@
|
||||
import comicinfoReducer from "../reducers/comicinfo.reducer";
|
||||
import fileOpsReducer from "../reducers/fileops.reducer";
|
||||
import airdcppReducer from "../reducers/airdcpp.reducer";
|
||||
// import settingsReducer from "../reducers/settings.reducer";
|
||||
|
||||
export const reducers = {
|
||||
comicInfo: comicinfoReducer,
|
||||
fileOps: fileOpsReducer,
|
||||
airdcpp: airdcppReducer,
|
||||
// settings: settingsReducer,
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import { RootState } from "../store";
|
||||
import { isUndefined } from "lodash";
|
||||
import { SETTINGS_SERVICE_BASE_URI } from "../constants/endpoints";
|
||||
|
||||
export interface InitialState {
|
||||
data: object;
|
||||
inProgress: boolean;
|
||||
dbFlushed: boolean;
|
||||
torrentsList: Array<any>;
|
||||
}
|
||||
const initialState: InitialState = {
|
||||
data: {},
|
||||
inProgress: false,
|
||||
dbFlushed: false,
|
||||
torrentsList: [],
|
||||
};
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
name: "settings",
|
||||
initialState,
|
||||
reducers: {
|
||||
SETTINGS_CALL_IN_PROGRESS: (state) => {
|
||||
state.inProgress = true;
|
||||
},
|
||||
|
||||
SETTINGS_OBJECT_FETCHED: (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_OBJECT_DELETED: (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_DB_FLUSH_SUCCESS: (state, action) => {
|
||||
state.dbFlushed = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED: (state, action) => {
|
||||
console.log(state);
|
||||
console.log(action);
|
||||
state.torrentsList = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
SETTINGS_CALL_IN_PROGRESS,
|
||||
SETTINGS_OBJECT_FETCHED,
|
||||
SETTINGS_OBJECT_DELETED,
|
||||
SETTINGS_DB_FLUSH_SUCCESS,
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||
} = settingsSlice.actions;
|
||||
|
||||
// Other code such as selectors can use the imported `RootState` type
|
||||
export const torrentsList = (state: RootState) => state.settings.torrentsList;
|
||||
export const qBittorrentSettings = (state: RootState) => {
|
||||
console.log(state);
|
||||
if (!isUndefined(state.settings?.data?.bittorrent)) {
|
||||
return state.settings?.data?.bittorrent.client.host;
|
||||
}
|
||||
};
|
||||
export default settingsSlice.reducer;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user