Compare commits

..

32 Commits

Author SHA1 Message Date
7739d1ced7 Merge branch 'main' into dark-mode-refactor 2023-12-21 16:21:34 -05:00
0d438ab913 Removing yarn.lockfile 2023-12-21 16:16:31 -05:00
a7b9138f52 🏗️ Fixed fonts, and comic detail page first draft 2023-12-21 15:19:28 -05:00
0872aa4d8a 🏗️ Cleaned the table further 2023-12-20 00:08:16 -05:00
4d1d29a361 🏗️ Added icons and details to metadata 2023-12-18 23:31:43 -05:00
b1d8951842 🏗️ Refactoring table for library 2023-12-18 18:02:15 -05:00
1180a2b42c 🧹 Formatting in connection confirmation panels 2023-12-16 22:39:29 -05:00
5ffcb0d642 🪑 Refactoring the table 2023-12-16 22:02:56 -05:00
f293d9e3ba 🔠 Added a monospace font 2023-12-15 18:13:25 -05:00
1f6d6ca6b6 🏗️ Form refactor 2023-12-14 22:25:48 -05:00
a7508df0b3 🔧 Styling form inputs 2023-12-13 21:43:21 -05:00
81b590157e 🏗️ Cleaning up forms and cards 2023-12-13 12:30:14 -05:00
72a308801d 🏗️ Added a horizontal medium variant 2023-12-11 14:14:06 -05:00
e1be413ff6 🏗️ Building variants for Cards 2023-12-11 10:51:47 -05:00
435056048b 🏗️ Refactored the card grid on dashboard 2023-12-08 18:16:28 -05:00
026d5832c5 🏗️ Refactored Import socket events 2023-12-06 21:56:58 -06:00
a068b82db3 🏗️ Cleaned up past imports table 2023-12-06 10:58:22 -06:00
c74c443534 🪑 Cleaning up the table styles 2023-12-05 21:40:40 -06:00
701d216e44 🏗️ Building out the import page 2023-12-05 19:23:56 -06:00
4034c4e2b4 🌜 Added Dark mode to the body 2023-12-05 05:24:33 -05:00
9c4f2ae9b0 🏗️ Wiring up the dark mode toggle 2023-12-05 04:57:25 -05:00
a07c0357f0 🔧 Added code for dark mode toggle 2023-12-04 16:41:41 -05:00
4215470871 🔧 Added solar icons 2023-12-04 16:10:00 -05:00
38599fbc78 🖼️ Added solar icons 2023-12-04 12:31:31 -05:00
7f3a1caa3c 🏗️ Migrating Navbar to TailwindCSS 2023-12-04 11:12:44 -05:00
d36138c800 Update App.scss 2023-12-03 22:46:49 -05:00
1ed6a622d4 🌜 Trying dark mode on the react-select 2023-12-03 16:02:54 -05:00
29e0772a10 🌜 Initial Dark Mode support 2023-12-03 15:28:05 -05:00
57b713aca1 🏗️ Refactored the AirDC++ download panel 2023-12-02 11:38:17 -05:00
dfd99e45b6 🔧 Implementing download method 2023-11-29 23:24:34 -05:00
591ecb394c 🔧 Formatted the search query box 2023-11-29 23:08:00 -05:00
145427d3fd 🏗️ Acquisition Panel refactor WIP 2023-11-29 21:22:48 -05:00
133 changed files with 5221 additions and 41012 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@@ -4,7 +4,6 @@ comics/
docs/
userdata/
dist/
storybook-static/*
src/client/assets/scss/App.css
/server/
node_modules/

View File

@@ -1,36 +1,19 @@
# Use Node.js 22 as the base image
FROM node:22-alpine
FROM node:18.15.0-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Set the working directory inside the container
WORKDIR /threetwo
# Copy package.json and yarn.lock to leverage Docker cache
COPY package.json yarn.lock ./
COPY package.json ./
COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# Install build dependencies necessary for native modules (for node-sass)
RUN apk --no-cache add \
g++ \
make \
python3 \
autoconf \
automake \
libtool \
nasm \
git
# 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 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
# Start the application with yarn
ENTRYPOINT ["yarn", "start"]
ENTRYPOINT [ "npm", "start" ]

View File

@@ -6,25 +6,14 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots
#### Dashboard
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png)
#### Issue View
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel
@@ -39,8 +28,7 @@ ThreeTwo! currently is set up as:
1. The UI, this repo.
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
## Docker Instructions
@@ -55,18 +43,20 @@ For debugging and troubleshooting, you can run this app locally using these step
3. This will open `http://localhost:5173` in your default browser
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## Troubleshooting
## Troubleshooting
### Docker
1. `docker-compose up` is taking a long time
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
2. What folder do my comics go in?
Your comics go in the `comics` directory at the root of this project.
## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

View File

@@ -1 +0,0 @@
module.exports = 'test-file-stub';

View File

@@ -1,16 +0,0 @@
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

View File

@@ -1,28 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
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',
],
};

View File

@@ -1,25 +0,0 @@
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 Normal file
View File

@@ -0,0 +1,13 @@
{
"ignore": [
"**/*.test.ts",
"**/*.spec.ts",
"node_modules",
"src/client"
],
"watch": [
"src/server"
],
"exec": "tsc -p tsconfig.server.json && node server/",
"ext": "ts"
}

19434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,150 +1,126 @@
{
"name": "threetwo",
"version": "0.1.0",
"description": "ThreeTwo! A good comic book curator.",
"version": "0.0.2",
"description": "ThreeTwo! A comic book curator.",
"main": "server/index.js",
"typings": "server/index.js",
"scripts": {
"build": "vite build",
"dev": "rimraf dist && yarn build && vite",
"start": "yarn build && vite",
"dev": "rimraf dist && npm run build && vite",
"start": "npm run 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",
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
"knip": "knip"
"build-storybook": "storybook build"
},
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@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",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@fortawesome/fontawesome-free": "^6.3.0",
"@redux-devtools/extension": "^3.2.5",
"@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",
"@types/react-router-dom": "^5.3.3",
"@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",
"@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",
"babel-plugin-styled-components": "^2.1.4",
"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",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.5.1",
"express": "^4.17.1",
"filename-parser": "^1.0.2",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
"history": "^5.3.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",
"html-to-text": "^8.1.0",
"immer": "^10.0.3",
"jsdoc": "^3.6.10",
"lodash": "^4.17.21",
"pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1",
"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",
"rxjs": "^7.8.2",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"qs": "^6.10.5",
"react": "^18.2.0",
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.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-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"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-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14",
"vaul": "^1.1.2",
"vite": "^7.3.1",
"vite-plugin-html": "^3.2.2",
"websocket": "^1.0.35",
"zustand": "^5.0.11"
"vite": "^5.0.5",
"vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34",
"zustand": "^4.4.6"
},
"devDependencies": {
"@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.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"autoprefixer": "^10.4.27",
"@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",
"docdash": "^2.0.2",
"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",
"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.17.1",
"install": "^0.13.0",
"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.1",
"ts-jest": "^29.4.6",
"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.3.5",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^6.0.2",
"wait-on": "^9.0.4"
},
"resolutions": {
"jackspeak": "2.1.1"
"typescript": "^5.1.6"
}
}

View File

@@ -1,7 +1,7 @@
module.exports = {
plugins: {
"postcss-import": {},
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

View File

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

View File

@@ -55,6 +55,7 @@ export const toggleAirDCPPSocketConnectionStatus =
break;
default:
console.log("Can't set AirDC++ socket status.");
break;
}
};

View File

@@ -43,7 +43,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
});
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
};
@@ -73,9 +73,10 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
break;
default:
break;
console.log("Could not complete request.");
}
} catch (error) {
console.log(error);
dispatch({
type: CV_API_GENERIC_FAILURE,
error,
@@ -98,6 +99,7 @@ export const getIssuesForSeries =
comicObjectID,
},
});
console.log(issues);
dispatch({
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
issues: issues.data.results,

View File

@@ -34,6 +34,7 @@ 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";
@@ -150,7 +151,7 @@ export const getComicBooks = (options) => async (dispatch) => {
});
break;
default:
break;
console.log("Unrecognized comic status.");
}
};
@@ -218,11 +219,12 @@ export const fetchVolumeGroups = () => async (dispatch) => {
data: response.data,
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
};
export const fetchComicVineMatches =
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
console.log(issueSearchQuery);
try {
dispatch({
type: CV_API_CALL_IN_PROGRESS,
@@ -271,7 +273,7 @@ export const fetchComicVineMatches =
});
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
dispatch({

View File

@@ -7,6 +7,8 @@ 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__,

View File

@@ -0,0 +1,15 @@
@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");
}
}

View File

@@ -1,20 +1,13 @@
import React, { ReactElement, useEffect } from "react";
import React, { ReactElement } from "react";
import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
import "../assets/scss/App.css";
import { useStore } from "../store";
import "../assets/scss/App.scss";
export const App = (): ReactElement => {
useEffect(() => {
useStore.getState().getSocket("/"); // Connect to the base namespace
}, []);
return (
<>
<Navbar2 />
<Outlet />
<ToastContainer stacked hideProgressBar />
</>
);
};

View File

@@ -1,10 +1,5 @@
import React, {
useCallback,
ReactElement,
useEffect,
useRef,
useState,
} from "react";
import React, { useCallback, ReactElement, useEffect, useState } from "react";
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize";
@@ -13,10 +8,8 @@ import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } 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;
@@ -28,117 +21,150 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const socketRef = useRef<Socket>();
const queryClient = useQueryClient();
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
airDCPPDownloadTick,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
})),
);
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
/**
* 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({});
// 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(() => {
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(() => {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs?.data, (item) => item.value),
hub_urls: map(hubs, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, [hubs, sanitizedIssueName]);
}, []);
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 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);
console.log("Asdas", airDCPPSearchInfo);
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;
}
};
/**
* 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,
@@ -146,213 +172,252 @@ export const AcquisitionPanel = (
name: String,
size: Number,
type: any,
config: any,
): Promise<void> => {
socketRef.current?.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => {
// Download initiated
},
);
};
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,
},
});
// 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;
}
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: [hubs?.data[0].hub_url],
hub_urls: map(hubs, (hub) => hub.hub_url),
priority: 5,
};
search(manualQuery);
search(manualQuery, airDCPPSocketInstance);
};
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
// dispatch(
// downloadAirDCPPItem(
// searchInstanceId,
// resultId,
// props.comicObjectId,
// name,
// size,
// type,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
// this is to update the download count badge on the downloads tab
// dispatch(
// getBundlesForComic(
// props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
},
[],
);
return (
<>
<div className="mt-5 mb-3">
{!isEmpty(hubs?.data) ? (
<div className="comic-detail columns">
{!isEmpty(airDCPPSocketInstance) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
<form
onSubmit={handleSubmit}
className="column is-three-quarters"
>
<div className="box search">
<div className="columns">
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info"></span>
</div>
<input
{...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
<button
className="sm:mt-0 min-w-fit rounded-r-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"
type="submit"
>
<div className="flex flex-row">
Search DC++
<div className="h-5 w-5 ml-2">
<img
src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</div>
</button>
</div>
</div>
);
}}
</Field>
<div className="column">
<button
type="submit"
className={
airDCPPSearchStatus
? "button is-loading is-warning"
: "button is-success is-light"
}
>
<span className="icon is-small">
<img src="/src/client/assets/img/airdcpp_logo.svg" />
</span>
<span className="airdcpp-text">Search on AirDC++</span>
</button>
</div>
</div>
</div>
</form>
)}
/>
) : (
<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 &gt; AirDC++ &gt; Hubs</code>.
</article>
<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 &gt; AirDC++ &gt; Connection</code>.
</div>
</article>
</div>
)}
</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) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs?.data.map((value, idx: string) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
</div>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="">
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<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">
<div className="column">
<table className="table">
<thead>
<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 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 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 px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Actions
</th>
<tr>
<th>Name</th>
<th>Type</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{map(
airDCPPSearchResults,
({ dupe, type, name, id, slots, users, size }, idx) => (
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
<tr
key={idx}
className={
!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"
!isNil(result.dupe) ? "dupe-search-result" : ""
}
>
{/* NAME */}
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
<td>
<p className="mb-2">
{type.id === "directory" && (
<i className="fas fa-folder mr-1"></i>
)}
{ellipsize(name, 45)}
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}
{ellipsize(result.name, 70)}
</p>
<dl>
<dd>
<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}
<div className="tags">
{!isNil(result.dupe) ? (
<span className="tag is-warning">Dupe</span>
) : null}
<span className="tag is-light is-info">
{result.users.user.nicks}
</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>
{result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}>
{flag}
</span>
))}
@@ -360,77 +425,70 @@ export const AcquisitionPanel = (
</dd>
</dl>
</td>
{/* 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}
<td>
<span className="tag is-light is-info">
{result.type.id === "directory"
? "directory"
: result.type.str}
</span>
</td>
{/* 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>
<div className="tags has-addons">
<span className="tag is-success">
{result.slots.free} free
</span>
<span className="tag is-light">
{result.slots.total}
</span>
</div>
</td>
{/* ACTIONS */}
<td className="px-2 py-3">
<td>
<button
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"
className="button is-small is-light is-success"
onClick={() =>
download(
airDCPPSearchInstance.id,
id,
result.id,
comicObjectId,
name,
size,
type,
{
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
result.name,
result.size,
result.type,
airDCPPSocketInstance,
)
}
>
Download
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
<span className="icon">
<i className="fas fa-file-download"></i>
</span>
<span>Download </span>
</button>
</td>
</tr>
),
)}
);
})}
</tbody>
</table>
</div>
) : (
<div className="">
<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>
The default search term is an auto-detected title; you may need
to change it to get better matches if the auto-detected one
doesn't work.
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
<p>
The default search term is an auto-detected title; you may
need to change it to get better matches if the auto-detected
one doesn't work.
</p>
</div>
</article>
<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>
Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from{" "}
<code className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
<p className="content">
Searching via <strong>AirDC++</strong> is still in
<strong>alpha</strong>. Some searches may take arbitrarily
long, or may not work at all. Searches from <code>ADCS</code>
hubs are more reliable than <code>NMDCS</code> ones.
</p>
</div>
</article>
</div>

View File

@@ -1,23 +1,94 @@
import React, { ReactElement } from "react";
import Select from "react-select";
import { filter, isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useCallback } from "react";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => {
const {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
} = props.configuration;
const { data } = props;
const { setSlidingPanelContentId, setVisible } = props.handlers;
const openDrawerWithCVMatches = useCallback(() => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
if (!isUndefined(data.rawFileDetails)) {
issueSearchQuery = refineQuery(data.rawFileDetails.name);
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</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 },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
option: (base, { data, isDisabled, isFocused, isSelected }) => {
return {
...base,
backgroundColor: isFocused ? "gray" : "black",
};
},
control: (base) => ({
...base,
backgroundColor: "black",
border: "1px solid #CCC",
}),
};
return (
<Select
components={{ Placeholder }}
placeholder={
<span className="inline-flex flex-row items-center gap-2 pt-1">
<div className="w-6 h-6">
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
</div>
<div>Select An Action</div>
<span>
<i className="fa-solid fa-list"></i> Actions
</span>
}
styles={customStyles}

View File

@@ -1,62 +0,0 @@
import React from "react";
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-6">
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
<thead>
<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="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Size
</th>
<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="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>
{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="px-3 py-2 align-top">
{prettyBytes(bundle.size)}
</td>
<td className="px-3 py-2 align-top">
<DownloadProgressTick bundleId={bundle.id} />
</td>
<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>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -1,17 +1,11 @@
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);
interface AsyncSelectPaginateProps {
metronResource: string;
placeholder?: string;
value?: object;
onChange?(...args: unknown[]): unknown;
}
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
export const AsyncSelectPaginate = (props): ReactElement => {
const [value, setValue] = useState(null);
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
@@ -44,4 +38,11 @@ export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactEleme
);
};
AsyncSelectPaginate.propTypes = {
metronResource: PropTypes.string.isRequired,
placeholder: PropTypes.string,
value: PropTypes.object,
onChange: PropTypes.func,
};
export default AsyncSelectPaginate;

View File

@@ -1,70 +1,44 @@
import React, { useState, ReactElement, useCallback, useMemo } from "react";
import React, { useState, ReactElement, useCallback } 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 { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select";
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
import AcquisitionPanel from "./AcquisitionPanel";
import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash";
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 { styled } from "styled-components";
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 = {};
/**
* Displays full comic detail: cover, file info, action menu, and tabbed panels
* for metadata, archive operations, and acquisition.
* Component for displaying the metadata for a comic in greater detail.
*
* @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`.
* @component
* @example
* return (
* <ComicDetail/>
* )
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
data: {
@@ -74,65 +48,111 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
updatedAt,
},
userSettings,
queryClient,
comicObjectId: comicObjectIdProp,
} = data;
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Action event handlers
const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
// const dispatch = useDispatch();
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
const openModal = useCallback((filePath) => {
setIsOpen(true);
// dispatch(
// extractComicArchive(filePath, {
// type: "full",
// purpose: "reading",
// imageResizeOptions: {
// baseWidth: 1024,
// },
// }),
// );
}, []);
// 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 afterOpenModal = useCallback((things) => {
// references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
console.log("kolaveri", things);
}, []);
const handleActionSelection = (action: ActionOption) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
break;
}
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// sliding panel init
const contentForSlidingPanel = {
CVMatches: {
content: (props) => (
<>
{/* <div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
</div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
{inferredMetadata.issue ? (
<div className="ml-3">
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
<span className="tag"> # {inferredMetadata.issue.number} </span>
</div>
) : null}
{!comicVineAPICallProgress ? (
<ComicVineMatchPanel
props={{
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
comicObjectId,
}}
/>
) : (
<div className="progress-indicator-container">
<div className="indicator">
<Loader
type="MutatingDots"
color="#CCC"
secondaryColor="#999"
height={100}
width={100}
visible={comicVineAPICallProgress}
/>
</div>
</div>
)} */}
</>
),
},
editComicBookMetadata: {
content: () => <EditMetadataPanel />,
},
};
// Check for metadata availability
// check for the availability of CV metadata
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
const hasAnyMetadata =
isComicBookMetadataAvailable ||
!isEmpty(comicInfo) ||
!isNil(locg);
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
const { issueName, url } = determineCoverFile({
rawFileDetails,
@@ -140,58 +160,95 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
locg,
});
// 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;
}
// query for airdc++
const airDCPPQuery = {
issue: {
name: issueName,
},
};
// Tab content and header details
const tabGroup = [
{
id: 1,
name: "Volume Information",
icon: <i className="fa-solid fa-layer-group"></i>,
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>,
content: (
<div className="columns" key={2}>
<div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
name: "Archive Operations",
content: <></>,
/*
<ArchiveOperations data={data.data} key={3} /> */
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: <i className="fa-solid fa-circle-nodes"></i>,
name: "DC++ Search",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
icon: <i className="fa-solid fa-droplet"></i>,
name: "Torrent Search",
content: <>Torrents</>,
shouldShow: true,
},
{
id: 6,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
) : (
"Downloads"
),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
),
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="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<section className="container mx-auto">
<div className="section">
{!isNil(data) && !isEmpty(data) && (
<>
@@ -205,49 +262,61 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{/* raw file details */}
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails?.cover) && (
!isEmpty(rawFileDetails.cover) && (
<div className="grid">
<RawFileDetails
data={{
rawFileDetails,
inferredMetadata,
createdAt,
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
}}
>
{/* action dropdown */}
<div className="mt-1 flex flex-row gap-2 w-full">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
configuration={{
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
</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>
)}
{/* action dropdown */}
{/* <div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div> */}
</div>
</div>
<TabControls
filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
activeTab={activeTab}
setActiveTab={setActiveTab}
acquisition={acquisition}
/>
<StyledSlidingPanel
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
width={"600px"}
>
{renderSlidingPanelContent()}
</StyledSlidingPanel>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</>
)}
</div>

View File

@@ -1,40 +1,42 @@
import React, { ReactElement } from "react";
import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQueryClient } from "@tanstack/react-query";
import { useGetComicByIdQuery } from "../../graphql/generated";
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
export const ComicDetailContainer = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const queryClient = useQueryClient();
const {
data: comicBookDetailData,
isLoading,
isError,
} = useGetComicByIdQuery(
{ id: comicObjectId! },
{ enabled: !!comicObjectId }
} = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
);
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;
};

View File

@@ -1,118 +1,119 @@
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 { isUndefined } from "lodash";
import Card from "../shared/Carda";
import { convert } from "html-to-text";
interface ComicVineDetailsProps {
updatedAt?: string;
data?: {
name?: string;
number?: string;
resource_type?: string;
id?: number;
};
}
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (
<div className="text-slate-500 dark:text-gray-400">
<div className="">
<div>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
</div>
<div className="column is-half">
<div className="comic-detail comicvine-metadata">
<dl>
<dt>ComicVine Metadata</dt>
<dd className="is-size-7">
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</dd>
{/* Comicvine metadata */}
<div className="mt-2">
<div className="text-md">ComicVine Metadata</div>
<div className="text-sm">
Last scraped on{" "}
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</div>
<div className="text-sm">
ComicVine Issue ID
<span>{data.id}</span>
</div>
</div>
</div>
{/* Publisher details */}
<div className="ml-8">
Published by{" "}
<span>{data.volumeInformation.publisher.name}</span>
<div>
Total issues in this volume{" "}
<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="text-md text-slate-900 dark:text-slate-900">
{data.volumeInformation.count_of_issues}
</span>
<dd>
<div className="columns mt-2">
<div className="column is-2">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column is-10">
<dl>
<dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
</dt>
<dd>
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
<div>
{data.issue_number && (
<div className="">
<span>Issue Number</span>
<span>{data.issue_number}</span>
</dd>
<dd>
Published by
<span className="has-text-weight-semibold">
{" "}
{data.volumeInformation.publisher.name}
</span>
</dd>
<dd>
Total issues in this volume:
{data.volumeInformation.count_of_issues}
</dd>
<dd>
<div className="field is-grouped mt-2">
{data.issue_number && (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Issue Number</span>
<span className="tag is-warning">
{data.issue_number}
</span>
</div>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Detected Type</span>
<span className="tag is-warning">
{
detectIssueTypes(
data.volumeInformation.description,
).displayName
}
</span>
</div>
</div>
) : data.resource_type ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Type</span>
<span className="tag is-warning">
{data.resource_type}
</span>
</div>
</div>
) : null}
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">
ComicVine Issue ID
</span>
<span className="tag is-success">{data.id}</span>
</div>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
</span>
</div>
) : data.resource_type ? (
<div>
<span>Type</span>
<span>{data.resource_type}</span>
</div>
) : null}
</div>
</div>
</div>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</dd>
</dl>
</div>
</div>
);
};
export default ComicVineDetails;
ComicVineDetails.propTypes = {
updatedAt: PropTypes.string,
data: PropTypes.shape({
name: PropTypes.string,
number: PropTypes.string,
resource_type: PropTypes.string,
id: PropTypes.number,
}),
};

View File

@@ -1,53 +1,23 @@
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";
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,
})),
);
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const {
comicObjectId,
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
} = comicVineData.props;
return (
<>
<div>
{!isEmpty(comicVineMatches) ? (
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<MatchResult
matchData={comicVineMatches}
matchData={comicVineSearchResults}
comicObjectId={comicObjectId}
queryClient={queryClient}
onMatchApplied={onMatchApplied}
/>
) : (
<>
<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 text-sm"
>
<div>
<p>ComicVine match results are an approximation.</p>
<p>
Auto-matching is not available yet. If you see no results or
poor quality ones, you can override the search query
parameters to get better ones.
</p>
</div>
</article>
<div className="text-md my-5">{comicvine.scrapingStatus}</div>
</>
)}
</div>
</>

View File

@@ -34,55 +34,83 @@ export const ComicVineSearchForm = (data) => {
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="flex items-center">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<span className="field is-normal">
<label className="label mb-2 is-size-5">Search Manually</label>
</span>
<label className="block py-1">Issue Name</label>
<Field name="issueName">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-full rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type the issue name"
/>
)}
</Field>
<div className="flex flex-row gap-4">
<div>
<label className="block py-1">Number</label>
<Field name="issueNumber">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-14 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="#"
/>
)}
</Field>
</div>
<div>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueName">
{(props) => (
<p className="control is-expanded has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue name"
/>
<span className="icon is-small is-left">
<i className="fas fa-journal-whills"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="flex justify-end mt-5">
<button
type="submit"
className="flex h-10 sm:mt-3 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-red-600 focus:outline-none focus:ring active:text-indigo-500"
>
Search
</button>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueNumber">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div>
</div>
</form>

View File

@@ -1,127 +1,32 @@
import prettyBytes from "pretty-bytes";
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);
import React, { ReactElement } from "react";
export const DownloadProgressTick = (props): ReactElement => {
return (
<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>
<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>
{/* 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>{props.data.target}</div>
</div>
);
};

View File

@@ -1,154 +1,103 @@
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";
import axios from "axios";
import {
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
export interface TorrentDetails {
infoHash: string;
progress: number;
downloadSpeed?: number;
uploadSpeed?: number;
interface IDownloadsPanelProps {
data: any;
comicObjectId: string;
}
/**
* 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 [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
"directconnect",
);
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
// const bundles = useSelector((state: RootState) => {
// return state.airdcpp.bundles;
// });
//
// // AirDCPP Socket initialization
// const userSettings = useSelector((state: RootState) => state.settings.data);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { socketIOInstance } = useStore(
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
/**
* Registers socket listeners on mount and cleans up on unmount.
*/
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => {
if (!socketIOInstance) return;
try {
if (!isEmpty(userSettings)) {
// dispatch(
// getBundlesForComic(props.comicObjectId, socket, {
// username: `${settings.directConnect.client.host.username}`,
// password: `${settings.directConnect.client.host.password}`,
// }),
// );
}
} catch (error) {
throw new Error(error);
}
}, [airDCPPConfiguration]);
/**
* 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];
}
const next = [...prev];
next[idx] = { ...next[idx], ...data };
return next;
});
};
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
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}/getBundles`,
method: "POST",
data: {
comicObjectId,
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
},
}),
});
// ————— 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(() => {
if (activeTab !== "torrents") return;
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
}, [activeTab]);
return (
return !isNil(props.data) ? (
<>
<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 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 className="columns is-multiline">
{!isEmpty(socket) ? (
<Bundles data={bundles} />
) : (
<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>
)}
</div>
</>
);
) : null;
};
export default DownloadsPanel;

View File

@@ -1,41 +1,53 @@
import React, { ReactElement } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { Form, Field } 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";
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 => {
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
const onSubmit = async () => {};
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}
mutators={{ ...arrayMutators }}
validate={validate}
mutators={{
...arrayMutators,
}}
render={({
handleSubmit,
form: {
mutators: { push, pop },
},
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
@@ -44,59 +56,93 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
<label className="label">Issue Details</label>
</div>
<div className="field-body">
<Field
name="issue_name"
component="input"
className="appearance-none w-full dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
initialValue={data.name}
placeholder={"Issue Name"}
/>
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="issue_name"
component="input"
className="input"
initialValue={rawFileDetails}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
</div>
</div>
{/* Issue Number and year */}
<div className="mt-4 flex flex-row gap-2">
<div>
<div className="text-sm">Issue Number</div>
<Field
name="issue_number"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Issue Number"
/>
<p className="text-xs">Do not enter the first zero</p>
</div>
<div>
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
/>
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="issue_number"
component="input"
className="input"
placeholder="Issue Number"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
</div>
{/* year */}
<div className="field">
<p className="control">
<Field
name="issue_year"
component="input"
className="input"
/>
</p>
</div>
</div>
</div>
<div>
<div className="text-sm">Page Count</div>
<Field
name="page_count"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Page Count"
/>
{/* page count */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="page_count"
component="input"
className="input"
placeholder="Page Count"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-note-sticky"></i>
</span>
</p>
</div>
</div>
</div>
{/* Description */}
<div className="mt-2">
<label className="text-sm">Description</label>
<Field
name={"description"}
className="dark:bg-slate-400 w-full min-h-24 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Description</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"description"}
className="textarea"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
</div>
<hr />
<hr size="1" />
<div className="field is-horizontal">
<div className="field-label">
@@ -136,7 +182,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
</div>
</div>
<hr />
<hr size="1" />
{/* Publisher */}
<div className="field is-horizontal">
@@ -207,7 +253,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
</div>
</div>
<hr />
<hr size="1" />
{/* team credits */}
<div className="field is-horizontal">
@@ -285,6 +331,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>

View File

@@ -1,16 +1,12 @@
import React from "react";
import React, { useCallback } from "react";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
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) => {
@@ -18,43 +14,14 @@ const handleBrokenImage = (e) => {
};
export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (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;
}
};
const applyCVMatch = useCallback(
// (match, comicObjectId) => {
// dispatch(applyComicVineMatch(match, comicObjectId));
// },
[],
);
return (
<>
<span className="flex items-center mt-6">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
ComicVine Matches
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
@@ -64,90 +31,88 @@ export const MatchResult = (props: MatchResultProps) => {
},
});
}
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<div className="search-result mb-4" key={idx}>
<div className="columns">
<div className="column is-one-fifth">
<img
className="rounded-md"
className="cover-image"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
{/* score */}
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.score, 10)}
</span>
</span>
<div className="search-result-details column">
<div className="is-size-5">{match.name}</div>
<div className="field is-grouped is-grouped-multiline mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary">
{match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div>
<span className="flex flex-row gap-2 mb-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium 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-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.issue_number, 10)}
</span>
</span>
<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--calendar-mark-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
Cover Date: {match.cover_date}
</span>
</span>
</span>
<div className="text-sm">
<div className="is-size-7">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="flex flex-row gap-2 my-4 ml-10">
<div className="">
<div className="vertical-line"></div>
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img
src={match.volumeInformation.results.image.icon_url}
className="rounded-md w-full"
className="cover-image"
onError={handleBrokenImage}
/>
</div>
<div className="">
<span>{match.volume.name}</span>
<div className="text-sm">
<p>
Total Issues:
{match.volumeInformation.results.count_of_issues}
</p>
<p>
Published by{" "}
{match.volumeInformation.results.publisher.name}
</p>
<div className="column">
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Total Issues</span>
<span className="tag is-warning">
{match.volumeInformation.results.count_of_issues}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<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={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="text-md">Apply Match</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
</span>
</button>
<div className="columns">
<div className="column">
<button
className="button is-normal is-outlined is-primary is-light is-pulled-right"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="icon is-size-5">
<i className="fas fa-clipboard-check"></i>
</span>
<span>Apply Match</span>
</button>
</div>
</div>
</div>
);

View File

@@ -1,80 +1,66 @@
import React, { ReactElement, ReactNode } from "react";
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash";
import { format, parseISO, isValid } from "date-fns";
import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
import { format, parseISO } from "date-fns";
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 || {};
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data;
const PaperClipIcon = () => <></>;
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>
<p className="text-gray-500">
<span className="text-xl">{rawFileDetails.name}</span>
</p>
</div>
<div className="px-4 py-5 sm:px-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-gray-500">
Raw File Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{rawFileDetails?.containedIn}
{"/"}
{rawFileDetails?.name}
{rawFileDetails?.extension}
<dd className="mt-1 text-sm text-gray-900">
{rawFileDetails.containedIn +
"/" +
rawFileDetails.name +
rawFileDetails.extension}
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-gray-500">
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) ? (
<dd className="mt-1 text-sm text-gray-900">
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>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
MIMEType
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
<dt className="text-sm font-medium text-gray-500">MIMEType</dt>
<dd className="mt-1 text-sm text-gray-900">
{/* 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="pt-1">
<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">
{rawFileDetails?.mimeType}
{rawFileDetails.mimeType}
</span>
</span>
</dd>
<dd className="text-sm text-gray-900"></dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
File Size
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
<dt className="text-sm font-medium text-gray-500">File Size</dt>
<dd className="mt-1 text-sm text-gray-900">
{/* 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">
@@ -82,29 +68,68 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-gray-500">
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{createdAt && isValid(parseISO(createdAt)) ? (
<>
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(createdAt), "h aaaa")}
</>
) : "N/A"}
<dd className="mt-1 text-sm text-gray-900">
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Actions
</dt>
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
<dt className="text-sm font-medium text-gray-500">Attachments</dt>
<dd className="mt-1 text-sm text-gray-900">
<ul
role="list"
className="divide-y divide-gray-200 rounded-md border border-gray-200"
>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
<PaperClipIcon
className="h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
<span className="ml-2 w-0 flex-1 truncate">
resume_back_end_developer.pdf
</span>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => {}}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
</ul>
</dd>
</div>
</dl>
</div>
@@ -114,3 +139,29 @@ export const RawFileDetails = (props: RawFileDetailsProps): 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,
}),
};

View File

@@ -1,65 +0,0 @@
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 ?? {}} />;

View File

@@ -1,59 +1,51 @@
import React, { ReactElement, Suspense, useState } from "react";
import { isNil } from "lodash";
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
export const TabControls = (props): ReactElement => {
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
// const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, acquisition } = 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;
useEffect(() => {
setActive(filteredTabs[0].id);
}, [acquisition]);
return (
<>
<div className="hidden sm:block mt-7 mb-3 w-fit">
<div className="hidden sm:block mt-7">
<div className="border-b border-gray-200">
<nav className="flex gap-6" aria-label="Tabs">
<nav className="-mb-px flex gap-6" aria-label="Tabs">
{filteredTabs.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 ${
currentActive === id
? "border-b border-cyan-50 dark:text-slate-200"
: "border-b border-transparent"
}`}
className="inline-flex shrink-0 items-center gap-2 border-b-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
aria-current="page"
onClick={() => handleSetActive(id)}
onClick={() => setActive(id)}
>
{/* Downloads tab and count badge */}
<>
{id === 6 && !isNil(downloadCount) ? (
<span className="inline-flex flex-row">
{/* download count */}
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
<span className="text-md text-slate-500 dark:text-slate-900">
{icon}
</span>
</span>
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
{/* <a>
{id === 6 && !isNil(acquisition.directconnect) ? (
<span className="download-icon-labels">
<i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light">
{acquisition.directconnect.downloads.length}
</span>
) : (
<span className="w-5 h-5">{icon}</span>
)}
{name}
</>
</span>
) : (
<span className="icon is-small">{icon}</span>
)}
{name}
</a> */}
{name}
</a>
))}
</nav>
</div>
</div>
<Suspense fallback={null}>
{filteredTabs.map(({ id, content }) => (
<React.Fragment key={id}>
{currentActive === id ? content : null}
</React.Fragment>
))}
</Suspense>
{filteredTabs.map(({ id, content }) => {
return active === id ? content : null;
})}
</>
);
};

View File

@@ -1,157 +1,57 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import React, { ReactElement, useCallback, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import {
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../../constants/endpoints";
import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow";
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
export const ArchiveOperations = (props: { data: any }): ReactElement => {
export const ArchiveOperations = (props): ReactElement => {
const { data } = props;
// const isComicBookExtractionInProgress = useSelector(
// (state: RootState) => state.fileOps.comicBookExtractionInProgress,
// );
// const extractedComicBookArchive = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
// );
//
// const imageAnalysisResult = useSelector((state: RootState) => {
// return state.fileOps.imageAnalysisResults;
// });
const unpackComicArchive = useCallback(() => {
// dispatch(
// extractComicArchive(data.rawFileDetails.filePath, {
// type: "full",
// purpose: "analysis",
// imageResizeOptions: {
// baseWidth: 275,
// },
// }),
// );
}, []);
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<string>("");
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false);
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
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;
if (data.rawFileDetails?.archive?.uncompressed) {
const fetchUncompressedArchive = async () => {
try {
const response = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
method: "POST",
data: {
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
},
transformResponse: async (responseData) => {
const parsedData = JSON.parse(responseData);
const paths = parsedData.map((pathObject: any) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
});
const uncompressedArchive = constructImagePaths(paths);
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
}
},
});
} catch (error) {
// Error handling could be added here if needed
}
};
fetchUncompressedArchive();
}
// Cleanup function
return () => {
isMounted = false;
setUncompressedArchive([]);
};
}, [data]);
const analyzeImage = async (imageFilePath: string) => {
const response = await axios({
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
method: "POST",
data: {
imageFilePath,
},
});
setImageAnalysisResult(response?.data);
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
};
const {
data: uncompressionResult,
refetch,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
method: "POST",
url: `http://localhost:3000/api/library/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: data.rawFileDetails.filePath,
comicObjectId: data._id,
options: {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
},
},
}),
queryKey: ["uncompressedArchive"],
enabled: false,
});
useEffect(() => {
if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
}
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
const [currentImage, setCurrentImage] = useState([]);
// sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
const contentForSlidingPanel = {
imageAnalysis: {
content: () => {
return (
<div>
<pre className="text-sm">{currentImage}</pre>
<pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3">
<pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
<pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre>
</div>
);
@@ -160,83 +60,56 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
};
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath);
// dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<article
role="alert"
className="mt-4 text-md 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"
<button
className={
isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
>
<div>
<p>You can perform several operations on your comic book archive.</p>
<p>
Uncompressing, re-organizing the individual pages, then
re-compressing to a different format, for example.
</p>
<p>You can also analyze color histograms of pages.</p>
</div>
</article>
<div className="mt-5">
{data.rawFileDetails.archive?.uncompressed &&
!isEmpty(uncompressedArchive) ? (
<article
role="alert"
className="mt-4 text-md 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"
>
This issue is already uncompressed at:
<p>
<code className="font-hasklig text-sm">
{data.rawFileDetails.archive.expandedPath}
</code>
<div className="">It has {uncompressedArchive?.length} pages</div>
</p>
</article>
) : null}
<div className="flex flex-row gap-2 mt-4">
{isEmpty(uncompressedArchive) ? (
<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-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Unpack Comic Archive</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
</span>
</button>
) : null}
{!isEmpty(uncompressedArchive) ? (
<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-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Convert to .cbz</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
</span>
</button>
</div>
) : null}
</div>
</div>
<div>
<div className="mt-10">
{!isEmpty(uncompressedArchive) ? (
<span className="icon is-small">
<i className="fa-solid fa-box-open"></i>
</span>
<span>Unpack comic archive</span>
</button>
<div className="columns">
<div className="mt-5">
{!isEmpty(extractedComicBookArchive) ? (
<DnD
data={uncompressedArchive}
data={extractedComicBookArchive}
onClickHandler={openImageAnalysisPanel}
/>
) : null}
</div>
{!isEmpty(extractedComicBookArchive) ? (
<div className="column mt-5">
<Sticky enabled={true} top={70} bottomBoundary={3000}>
<div className="card">
<div className="card-content">
<span className="has-text-size-4">
{extractedComicBookArchive.length} pages
</span>
<button className="button is-small is-light is-primary is-outlined">
<span className="icon is-small">
<i className="fa-solid fa-compress"></i>
</span>
<span>Convert to CBZ</span>
</button>
</div>
</div>
</Sticky>
</div>
) : null}
</div>
<SlidingPane
isOpen={visible}

View File

@@ -1,67 +1,56 @@
import { isUndefined } from "lodash";
import React, { ReactElement } from "react";
export const ComicInfoXML = (data: { json: any }): ReactElement => {
export const ComicInfoXML = (data): ReactElement => {
const { json } = data;
return (
<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>
</dt>
<dd className="text-sm">
published by{" "}
<span className="underline">
{json.publisher?.[0]}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</dd>
<span className="inline-flex flex-row gap-2">
{/* Issue number */}
{!isUndefined(json.number) && (
<dd className="my-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium 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-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(json.number[0], 10)}
<div className="comicInfo-metadata">
<dl className="has-text-size-7">
<dd className="has-text-weight-medium">{json.series[0]}</dd>
<dd className="mt-2 mb-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.publisher[0]}
</span>
</span>
</dd>
)}
{/* 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]}
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Issue #</span>
<span className="tag is-warning is-light">
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
</span>
</span>
</dd>
)}
</span>
<dd className="my-1">
{/* Summary */}
{!isUndefined(json.summary) && (
<span className="text-md text-slate-500 dark:text-slate-900">
{json.summary[0]}
</span>
)}
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.pagecount[0]}
</span>
</span>
</div>
{!isUndefined(json.genre) && (
<div className="control">
<span className="tags has-addons">
<span className="tag">Genre</span>
<span className="tag is-success is-light">
{json.genre[0]}
</span>
</span>
</div>
)}
</div>
</dd>
<dd>
<span className="is-size-7">{json.notes[0]}</span>
</dd>
<dd className="mt-1 mb-1">
{!isUndefined(json.summary) && json.summary[0]}
</dd>
{!isUndefined(json.notes) && (
<dd>
{/* Notes */}
<span className="text-sm text-slate-500 dark:text-slate-900">
{json.notes[0]}
</span>
</dd>
)}
</dl>
</div>
);

View File

@@ -1,165 +1,30 @@
import React, { ReactElement, useMemo, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { Drawer } from "vaul";
import React, { ReactElement } from "react";
import ComicVineDetails from "../ComicVineDetails";
import { convert } from "html-to-text";
import { isEmpty } from "lodash";
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 {
sourcedMetadata?: SourcedMetadata;
inferredMetadata?: { issue?: unknown };
updatedAt?: string;
}
interface VolumeInformationProps {
data: VolumeInformationData;
onReconcile?: () => void;
}
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
const SOURCED_METADATA_KEYS = [
"comicvine",
"locg",
"comicInfo",
"metron",
"gcd",
];
const SOURCE_LABELS: Record<string, string> = {
comicvine: "ComicVine",
locg: "League of Comic Geeks",
comicInfo: "ComicInfo.xml",
metron: "Metron",
gcd: "Grand Comics Database",
inferredMetadata: "Local File",
};
const SOURCE_ICONS: Record<string, string> = {
comicvine: "icon-[solar--database-bold]",
locg: "icon-[solar--users-group-rounded-outline]",
comicInfo: "icon-[solar--file-text-outline]",
metron: "icon-[solar--planet-outline]",
gcd: "icon-[solar--book-outline]",
inferredMetadata: "icon-[solar--folder-outline]",
};
const MetadataSourceChips = ({
sources,
}: {
sources: string[];
}): ReactElement => {
const [isSheetOpen, setSheetOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
<div className="flex flex-row items-center justify-between">
<span className="text-md text-slate-500 dark:text-slate-400">
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
{sources.length} metadata sources detected
</span>
</div>
<div className="flex flex-row flex-wrap gap-2">
{sources.map((source) => (
<span
key={source}
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
>
<i
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
/>
{SOURCE_LABELS[source] ?? source}
</span>
))}
</div>
</div>
<button
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => setSheetOpen(true)}
>
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
Reconcile sources
</button>
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
<div className="p-4">
{/* Reconciliation UI goes here */}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</>
);
};
/**
* Displays volume metadata for a comic.
*
* - When multiple sources are present, renders a chip bar listing each source
* with a "Reconcile sources" action to merge them.
* - When exactly one source is present and it is ComicVine, renders the full
* ComicVine detail panel directly.
*
* @param props.data - Comic data containing sourced and inferred metadata.
* @param props.onReconcile - Called when the user triggers source reconciliation.
*/
export const VolumeInformation = (
props: VolumeInformationProps,
): ReactElement => {
export const VolumeInformation = (props): ReactElement => {
const { data } = props;
const presentSources = useMemo(() => {
const sources = SOURCED_METADATA_KEYS.filter((key) => {
const val = (data?.sourcedMetadata ?? {})[key];
if (isNil(val) || isEmpty(val)) return false;
// locg returns an object even when empty; require at least one non-null value
if (key === "locg")
return Object.values(val as Record<string, unknown>).some(
(v) => !isNil(v) && v !== "",
);
return true;
});
if (
!isNil(data?.inferredMetadata?.issue) &&
!isEmpty(data?.inferredMetadata?.issue)
) {
sources.push("inferredMetadata");
}
return sources;
}, [data?.sourcedMetadata, data?.inferredMetadata]);
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return (
<div key={1}>
{presentSources.length > 1 && (
<MetadataSourceChips sources={presentSources} />
)}
{presentSources.length === 1 &&
data.sourcedMetadata?.comicvine?.volumeInformation && (
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
)}
<div className="columns is-multiline">
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
<div className="column is-8">
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
convert(data.sourcedMetadata.comicvine.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</div>
);
};

View File

@@ -1,76 +0,0 @@
import React from "react";
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
export const TorrentDownloads = (props) => {
const { data } = props;
return (
<>
{data.map(({ torrent }) => {
return (
<dl className="mt-5 dark:text-slate-200 text-slate-600">
<dt className="text-lg">{torrent.name}</dt>
<p className="text-sm">{torrent.hash}</p>
<p className="text-sm">
Added on {dayjs.unix(torrent.added_on).format("ddd, D MMM, YYYY")}
</p>
<p className="flex gap-2 mt-1">
{torrent.progress > 0 ? (
<>
<progress
className="w-80 mt-2 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-slate-300 [&::-webkit-progress-value]:bg-green-400 [&::-moz-progress-bar]:bg-green-400 h-2"
value={Math.floor(torrent.progress * 100).toString()}
max="100"
></progress>
<span>{Math.floor(torrent.progress * 100)}%</span>
{/* downloaded/left */}
<p className="inline-flex items-center bg-slate-200 text-green-800 dark:text-green-900 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-slate-400">
<span className="pr-1">
<i className="icon-[solar--arrow-to-down-left-outline] h-4 w-4"></i>
</span>
<span className="text-md">
{prettyBytes(torrent.downloaded)}
</span>
{/* uploaded */}
<span className="pr-1 text-orange-800 dark:text-orange-900 ml-2">
<i className="icon-[solar--arrow-to-top-left-outline] h-4 w-4"></i>
</span>
<span className="text-md text-orange-800 dark:text-orange-900">
{prettyBytes(torrent.uploaded)}
</span>
</p>
</>
) : null}
</p>
<div className="flex gap-4 mt-2">
{/* Peers */}
<span className="inline-flex items-center bg-slate-200 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">
<i className="icon-[solar--station-minimalistic-line-duotone] h-5 w-5"></i>
</span>
<span className="text-md text-slate-900">
{torrent.trackers_count}
</span>
</span>
{/* Size */}
<span className="inline-flex items-center bg-slate-200 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--mirror-right-bold-duotone] h-4 w-4"></i>
</span>
<span className="text-md text-slate-900">
{prettyBytes(torrent.total_size)}
</span>
</span>
</div>
</dl>
);
})}
</>
);
};
export default TorrentDownloads;

View File

@@ -1,202 +0,0 @@
import React, { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Form, Field } from "react-final-form";
import {
PROWLARR_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { isEmpty, isNil } from "lodash";
import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes";
export const TorrentSearchPanel = (props) => {
const { issueName, comicObjectId } = props;
// Initialize searchTerm with issueName from props
const [searchTerm, setSearchTerm] = useState({ issueName });
const [torrentToDownload, setTorrentToDownload] = useState("");
const { data, isSuccess, isLoading } = useQuery({
queryKey: ["searchResults", searchTerm.issueName],
queryFn: async () => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
method: "POST",
data: {
prowlarrQuery: {
port: "9696",
apiKey: "38c2656e8f5d4790962037b8c4416a8f",
offset: 0,
categories: [7030],
query: searchTerm.issueName,
host: "localhost",
limit: 100,
type: "search",
indexerIds: [2],
},
},
});
},
enabled: !isNil(searchTerm.issueName) && searchTerm.issueName.trim() !== "", // Make sure searchTerm is not empty
});
const mutation = useMutation({
mutationFn: async (newTorrent) =>
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
onSuccess: async (data) => {
// Torrent added successfully
},
});
const searchIndexer = (values) => {
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
};
const downloadTorrent = (evt) => {
const newTorrent = {
comicObjectId,
torrentToDownload: evt,
};
mutation.mutate(newTorrent);
};
return (
<>
<div className="mt-5">
<Form
onSubmit={searchIndexer}
initialValues={searchTerm}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
{/* Icon placeholder */}
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<input
{...input}
type="text"
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Enter a search term"
/>
<button
className="sm:mt-0 min-w-fit rounded-r-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"
type="submit"
>
<div className="flex flex-row">
Search Indexer
<div className="h-5 w-5 ml-1">
<i className="h-6 w-6 icon-[solar--magnet-bold-duotone]" />
</div>
</div>
</button>
</div>
</div>
)}
</Field>
</form>
)}
/>
</div>
<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>
The default search term is an auto-detected title; you may need to
change it to get better matches if the auto-detected one doesn't work.
</div>
</article>
{!isEmpty(data?.data) ? (
<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">
<thead>
<tr>
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
Name
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Indexer
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{data?.data.map((result, idx) => (
<tr key={idx}>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
<p>{ellipsize(result.fileName, 90)}</p>
{/* Seeders/Leechers */}
<div className="flex gap-3 mt-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--archive-up-minimlistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.seeders} seeders
</span>
</span>
<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--archive-down-minimlistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.leechers} leechers
</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>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(result.size)}
</span>
</span>
{/* Files */}
<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-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.files} files
</span>
</span>
</div>
</td>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
{result.indexer}
</td>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
<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={() => downloadTorrent(result.downloadUrl)}
>
<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>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
);
};
export default TorrentSearchPanel;

View File

@@ -1,65 +0,0 @@
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)",
}),
};

View File

@@ -1,114 +0,0 @@
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,
},
];
};

View File

@@ -1,89 +0,0 @@
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,
};
};

View File

@@ -1,72 +1,189 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
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 {
useGetRecentComicsQuery,
useGetWantedComicsQuery,
useGetVolumeGroupsQuery,
useGetLibraryStatisticsQuery
} from "../../graphql/generated";
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil, isUndefined } from "lodash";
import Header from "../shared/Header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Card } from "../shared/Carda";
import {
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../constants/endpoints";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
export const Dashboard = (): ReactElement => {
// Use GraphQL for recent comics
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
{ limit: 5 },
{ refetchOnWindowFocus: false }
);
// 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
}
);
// 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;
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"],
});
console.log("hari om", recentComics);
// useEffect(() => {
// dispatch(fetchVolumeGroups());
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": false },
// comicStatus: "recent",
// }),
// );
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": true },
// comicStatus: "wanted",
// }),
// );
// dispatch(getLibraryStatistics());
// }, []);
//
// const recentComics = useSelector(
// (state: RootState) => state.fileOps.recentComics,
// );
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
// const volumeGroups = useSelector(
// (state: RootState) => state.fileOps.comicVolumeGroups,
// );
//
// const libraryStatistics = useSelector(
// (state: RootState) => state.comicInfo.libraryStatistics,
// );
return (
<>
<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>
</>
<div className="container mx-auto max-w-full">
<section>
<h1>Dashboard</h1>
<div className="grid grid-cols-5 gap-6">
{recentComics?.data.docs.map(
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(
name,
{
comicvine,
comicInfo,
locg,
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
return (
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dt className="sr-only">Address</dt>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<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--hashtag-outline]"></i>
</span>
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<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--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
</dd>
</div>
<div className="flex flex-row items-center gap-1 my-2">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300"></i>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</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>
</section>
</div>
);
};

View File

@@ -1,105 +1,114 @@
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];
import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash";
export const LibraryStatistics = (
props: ILibraryStatisticsProps,
): ReactElement => {
// const { stats } = props;
return (
<div className="mt-5">
<Header
headerContent="Your Library In Numbers"
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<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
<h4 className="title is-4">
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
</h4>
<p className="subtitle is-7">A brief snapshot of your library.</p>
<div className="columns is-multiline">
<div className="column is-narrow is-two-quarter">
<dl className="box">
<dd className="is-size-4">
<span className="has-text-weight-bold">
{props.stats.totalDocuments}
</span>{" "}
files
</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>
)}
<dd className="is-size-4">
Library size
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)}
</span>
</dd>
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issues.length}
</span>{" "}
tagged with ComicVine
</dd>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
ComicInfo.xml
</span>
</dd>
)}
</dl>
</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>
)}
<div className="p-3 column is-one-quarter">
<dl className="box">
<dd className="is-size-6">
<span className="has-text-weight-bold"></span> Issues
</dd>
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
<dd className="is-size-6">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].fileTypes) &&
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
return (
<span key={idx}>
<span className="has-text-weight-bold">
{fileType.data.length}
</span>
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
{fileType._id}
</span>
</span>
);
})}
</dd>
</dl>
</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>
)}
{/* file types */}
<div className="p-3 column is-two-fifths">
{/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id
}
</span>
{" has the most issues "}
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count
}
</span>
</dd>
)}
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
</dl>
</div>
</div>
</div>
);

View File

@@ -1,180 +1,160 @@
import React, { ReactElement, useState } from "react";
import { map } from "lodash";
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom";
import axios from "axios";
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[];
}
export const PullList = (): ReactElement => {
const queryClient = useQueryClient();
// datepicker
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "yyyy/M/dd"),
export const PullList = ({ issues }: PullListProps): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2023-9-9",
pageSize: "15",
currentPage: "1",
}),
);
}, []);
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
);
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
const {
data: pullListData,
refetch,
isSuccess,
isLoading,
isError,
} = useGetWeeklyPullListQuery({
input: {
startDate: inputValue,
pageSize: 15,
currentPage: 1,
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
});
};
*/
// 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 } },
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
};
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"] });
},
});
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => {
// sliderRef.slickNext();
sliderRef.slickNext();
};
const previous = () => {
// sliderRef.slickPrev();
sliderRef.slickPrev();
};
return (
<>
<Header
headerContent="Discover"
subHeaderContent={
<span className="text-md">
Pull List aggregated for the week from{" "}
<span className="underline">
<a href="https://leagueofcomicgeeks.com">
League Of Comic Geeks
</a>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
}
iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/"
/>
<div className="flex flex-row gap-5 mb-3">
<div className="content">
<Header headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
iconClassNames="fa-solid fa-binoculars mr-2"/>
<div className="field is-grouped">
{/* select week */}
<div className="flex flex-row gap-4 my-3">
<Form
onSubmit={() => {}}
render={({ handleSubmit }) => (
<form>
<div className="flex flex-col gap-2">
{/* week selection for pull list */}
<DatePickerDialog
inputValue={inputValue}
setter={setInputValue}
/>
{inputValue && (
<div className="text-sm">
Showing pull list for{" "}
<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">
{inputValue}
</span>
</div>
)}
</div>
</form>
)}
/>
</div>
</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>
);
})}
<div className="control">
<div className="select is-small">
<select>
<option>Select Week</option>
<option>With options</option>
</select>
</div>
</div>
)}
{isLoading && <div>Loading...</div>}
{isError && <div>An error occurred while retrieving the pull list.</div>}
{/* See all pull list issues */}
<div className="control">
<Link to={"/pull-list/all/"}>
<button className="button is-small">View all issues</button>
</Link>
</div>
<div className="field has-addons">
<div className="control">
<button className="button is-rounded is-small" onClick={previous}>
<i className="fa-solid fa-caret-left"></i>
</button>
</div>
<div className="control">
<button className="button is-rounded is-small" onClick={next}>
<i className="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
</div>
</div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({ issue }, idx) => {
return (
<Card
key={idx}
orientation={"vertical"}
imageUrl={issue.cover}
hasDetails
title={ellipsize(issue.name, 18)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
>
<div className="content">
<div className="control">
<span className="tag">{issue.publisher}</span>
</div>
<div className="mt-2">
<button
className="button is-small is-success is-outlined is-light"
onClick={() => addToLibrary("locg", issue)}
>
<i className="fa-solid fa-plus"></i> Want
</button>
</div>
</div>
</Card>
);
})}
</Slider>
</>
);
};

View File

@@ -4,141 +4,143 @@ 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 } 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";
import Masonry from "react-masonry-css";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
type RecentlyImportedProps = {
comics: GetRecentComicsQuery['comics']['comics'];
comicBookCovers: any;
};
export const RecentlyImported = (
{ comics }: RecentlyImportedProps,
): ReactElement => {
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
return (
<div>
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<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,
<>
<div className="content mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
</h4>
<p className="subtitle is-7">
Recent Library activity such as imports, tagging, etc.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comicBookCovers,
(
{
_id,
rawFileDetails,
sourcedMetadata,
canonicalMetadata,
inferredMetadata,
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;
sourcedMetadata: { comicvine, comicInfo, locg },
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const isComicVineMetadataAvailable =
const { issue, coverURL, icon } = determineExternalMetadata(name, {
comicvine,
comicInfo,
locg,
});
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
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]"
>
<React.Fragment key={_id}>
<Card
orientation="vertical-2"
orientation={"vertical"}
imageUrl={url}
title={inferredMetadata?.issue?.name}
hasDetails
cardState={cardState}
title={issueName ? titleElement : null}
>
<div>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<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--hashtag-outline]"></i>
<div className="content is-flex is-flex-direction-row">
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="icon custom-icon is-small has-text-danger mr-2">
<img src="/src/client/assets/img/missing-file.svg" />
</span>
<span className="text-md text-slate-900">
{inferredMetadata?.issue?.number}
</span>
</span>
{/* File extension */}
<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--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.extension}
</span>
</span>
{/* Uncompressed status */}
{rawFileDetails?.archive?.uncompressed ? (
<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--bookmark-bold-duotone] w-4 h-4"></i>
</span>
</span>
) : null}
</dd>
</div>
<div className="flex flex-row items-center gap-1 mt-2 pb-1">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
)}
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<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="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
<span className="icon custom-icon is-small has-text-danger">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-full h-full object-contain"
src="/src/client/assets/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)}
{/* ComicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</div>
</Card>
</div>
</Card>
{/* metadata card */}
{!isNil(name) && (
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
<dd className="is-size-9">
<dl>
<span className="icon custom-icon">
<img src={`/src/client/assets/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
},
)}
</Masonry>
</>
);
};
};

View File

@@ -2,83 +2,62 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
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";
import Masonry from "react-masonry-css";
type VolumeGroupsProps = {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
};
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
const navigate = useNavigate();
const navigateToVolumes = (row: any) => {
const navigateToVolumes = (row) => {
navigate(`/volumes/all`);
};
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return (
<div>
<Header
headerContent="Volumes"
subHeaderContent={<>Based on ComicVine Volume information</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/volumes"}
/>
<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 || 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>
);
})}
</div>
</div>
<section className="volumes-container mt-4">
<div className="content">
<a className="mb-1" onClick={navigateToVolumes}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-layer-group"></i> Volumes
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
{map(deduplicatedGroups, (data) => {
return (
<div className="stack" key={data._id}>
<img src={data.volumes.image.small_url} />
<div className="content">
<div className="stack-title is-size-8">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 18)}
</Link>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag is-primary is-light">Issues</span>
<span className="tag">{data.volumes.count_of_issues}</span>
</span>
</div>
</div>
</div>
);
})}
</Masonry>
</section>
);
};

View File

@@ -4,135 +4,111 @@ import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
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?: GetWantedComicsQuery['getComicBooks']['docs'];
comics: any;
};
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement | null => {
if (!comics || comics.length === 0) return null;
}: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate();
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return (
<div>
<Header
headerContent="Wanted Comics"
subHeaderContent={<>Comics marked as wanted from various sources</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"}
/>
<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;
<>
<div className="content mt-6">
<a className="mb-1" onClick={navigateToWantedComics}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-asterisk"></i> Wanted Comics
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">
Comics marked as wanted from various sources.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comics,
({
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
// 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 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>
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/locglogo.svg" />
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
);
},
)}
</Masonry>
</>
);
};

View File

@@ -6,8 +6,8 @@ interface ZeroStateProps {
}
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
return (
<article className="">
<div className="">
<article className="message is-info">
<div className="message-body">
<p>{props.header}</p>
{props.message}
</div>

View File

@@ -57,12 +57,13 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
}, [issueBundles]);
return !isNil(bundles) ? (
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="container">
<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

View File

@@ -1,493 +0,0 @@
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 {};

View File

@@ -1,136 +1,125 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import React, { ReactElement, useCallback, useEffect } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, 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";
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
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 => {
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore(
const { importJobQueue, socketIOInstance } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
socketIOInstance: state.socketIOInstance,
})),
);
// 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`,
const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({
mutationFn: async () =>
await axios.request({
url: `http://localhost:3000/api/library/newImport`,
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, 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 { data, isError, isLoading } = useQuery({
queryKey: ["allImportJobResults"],
queryFn: async () =>
await axios({
method: "GET",
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
}),
});
const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
(data) => console.log(data),
);
};
/**
* Handles force re-import - re-imports all files to fix indexing issues
* 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
*/
const handleForceReImport = async () => {
setImportError(null);
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>
);
// 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;
}
case "drained":
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();
}
default:
return null;
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="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">
@@ -165,61 +154,70 @@ export const Import = (): ReactElement => {
</div>
</article>
{/* Import Statistics */}
<div className="my-6 max-w-screen-lg">
<RealTimeImportStats />
</div>
{/* 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>
<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>
<div className="my-4">
{importJobQueue.status === "drained" ||
(importJobQueue.status === undefined && (
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
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="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
<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>
))}
</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
</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>
</div>
<div className="flex">
<span className="mt-2 dark:text-slate-200 text-slate-400">
Imported: <span>{importJobQueue.mostRecentImport}</span>
</span>
</div>
</>
)}
{/* 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) && (
{/* Past imports */}
{!isLoading && !isEmpty(data?.data) && (
<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">
@@ -228,13 +226,10 @@ export const Import = (): ReactElement => {
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<div className="overflow-x-auto 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">
<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>
@@ -251,19 +246,14 @@ export const Import = (): ReactElement => {
</thead>
<tbody className="divide-y divide-gray-200">
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
{data?.data.map((jobResult, id) => {
return (
<tr key={index}>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
{index + 1}
</td>
<tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
? format(
new Date(parseInt(jobResult.earliestTimestamp)),
"EEEE, hh:mma, do LLLL y",
)
: "N/A"}
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">

View File

@@ -1,379 +0,0 @@
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;

View File

@@ -1,160 +1,83 @@
import React, { useMemo, ReactElement, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import axios from "axios";
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" },
];
import { format, fromUnixTime, parseISO } from "date-fns";
/**
* Library page component. Displays a paginated, searchable table of all comics
* in the collection, with an optional filter for comics with missing raw files.
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
*/
export const Library = (): ReactElement => {
const [searchParams] = useSearchParams();
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {},
pagination: { size: 25, from: 0 },
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/** Fetches a page of issues from the search API. */
const fetchIssues = async (q: SearchQuery) => {
const { pagination, query, type } = q;
// Method to fetch paginated issues
const fetchIssues = async (searchQuery, offset, type) => {
let pagination = {
size: 15,
from: offset,
};
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: { query, pagination, type },
data: {
searchQuery,
pagination,
type,
},
});
};
const { data, isPlaceholderData } = useQuery({
queryKey: ["comics", searchQuery],
queryFn: () => fetchIssues(searchQuery),
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset],
queryFn: () => fetchIssues({}, offset, "all"),
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;
// Programmatically navigate to comic detail
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 },
type: "volumeName",
trigger: "libraryPage",
});
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
};
/** Advances to the next page of results. */
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from: pageSize * pageIndex + 1 },
type: "all",
trigger: "libraryPage",
});
}
};
/** Goes back to the previous page of results. */
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from },
type: "all",
trigger: "libraryPage",
});
};
const ComicInfoXML = (value: any) =>
value.data ? (
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
<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>
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 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-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
{ellipsize(value.data.series[0], 25)}
<span className="text-md text-slate-900 dark:text-slate-900">
{ellipsize(value.data.series[0], 45)}
</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>
<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-xs sm:text-md text-slate-900 dark:text-slate-900">
<span className="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>
{/* 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) && (
@@ -165,62 +88,30 @@ export const Library = (): ReactElement => {
</div>
</dl>
) : null;
const missingFilesColumns = useMemo(
() => [
{
header: "Missing Files",
columns: [
{
header: "Status",
id: "missingStatus",
cell: () => (
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
MISSING
</span>
</div>
),
},
{
header: "Comic",
id: "missingComic",
minWidth: 250,
accessorFn: (row: any) => row,
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
},
],
},
],
[],
);
};
const columns = useMemo(
() => [
{
header: "Comic Metadata",
footer: 1,
columns: [
{
header: "File Details",
id: "fileDetails",
minWidth: 250,
minWidth: 400,
accessorKey: "_source",
cell: (info: any) => {
const source = info.getValue();
return (
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info: any) =>
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : null,
},
],
},
@@ -230,30 +121,36 @@ export const Library = (): ReactElement => {
{
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>
cell: (info) => {
return !isNil(info.getValue()) ? (
<div className="text-xs w-max ml-3 my-3 text-slate-600">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
{format(parseISO(info.getValue()), "h aaaa")}
</div>
) : null,
) : null;
},
},
{
header: "Downloads",
accessorKey: "_source.acquisition",
cell: (info: any) => (
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 whitespace-nowrap">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
DC++: {info.getValue().directconnect.downloads.length}
<span className="text-md text-slate-900 dark:text-slate-900">
DC++: {info.getValue().directconnect.downloads.length}
</span>
</span>
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
Torrent: {info.getValue().torrent.length}
<span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.downloads.length}
</span>
</span>
</div>
),
@@ -261,99 +158,107 @@ export const Library = (): ReactElement => {
],
},
],
[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>
);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
setOffset(pageSize * pageIndex + 1);
}
};
const isMissingFilter = activeFilter === "missingFiles";
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
setOffset(from);
};
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
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>
<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>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</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: () => {} }}
</header>
{!isUndefined(searchResults?.hits) ? (
<div>
<div className="library">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
/>
</div>
</div>
) : (
<>
<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"
>
<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>
<p>
No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come
back.
</p>
</div>
</article>
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
{!isUndefined(searchResults?.data?.meta?.body) ? (
<p>
{JSON.stringify(
searchResults.data.meta.body.error.root_cause,
null,
4,
)}
</p>
) : null}
</pre>
</div>
</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>
</>
)}
</section>
</div>
);
};

View File

@@ -72,11 +72,8 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
>
<div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && (
<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 className="icon cv-icon is-small">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{isNil(rawFileDetails) && (

View File

@@ -1,13 +1,32 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useCallback } from "react";
import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (props): ReactElement => {
const { searchHandler } = props;
export const SearchBar = (): ReactElement => {
const handleSubmit = useCallback((e) => {
// dispatch(
// searchIssue(
// {
// query: {
// volumeName: e.search,
// },
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumeName",
// trigger: "libraryPage",
// },
// ),
// );
}, []);
return (
<Form
onSubmit={searchHandler}
onSubmit={handleSubmit}
initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>

View File

@@ -99,7 +99,7 @@ export const PullList = (): ReactElement => {
[],
);
return (
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Weekly Pull List</h1>

View File

@@ -1,470 +1,180 @@
import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import React, { useCallback, ReactElement } from "react";
import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { importToDB } from "../../actions/fileops.actions";
import { useSelector, useDispatch } from "react-redux";
import { comicinfoAPICall } from "../../actions/comicinfo.actions";
import { search } from "../../services/api/SearchApi";
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 { useMutation, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {}
export const Search = ({}: ISearchProps): ReactElement => {
const queryClient = useQueryClient();
const formData = {
search: "",
};
const [comicVineMetadata, setComicVineMetadata] = useState({});
const [selectedResource, setSelectedResource] = useState("volume");
const { t } = useTranslation();
const handleResourceChange = (value) => {
setSelectedResource(value);
};
const {
mutate,
data: comicVineSearchResults,
isPending,
isSuccess,
} = 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: search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: resource,
},
});
},
});
// add to library
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: {
importStatus: {
isImported: false, // wanted, but not acquired yet.
tagged: false,
matchedResult: {
score: "0",
},
},
wanted: {
source,
markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
const dispatch = useDispatch();
const getCVSearchResults = useCallback(
(searchQuery) => {
dispatch(
comicinfoAPICall({
callURIAction: "search",
callMethod: "GET",
callParams: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery.search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
},
});
}),
);
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
[dispatch],
);
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const addToLibrary = useCallback(
(sourceName: string, comicData) =>
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => {
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="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">
Search
</h1>
<>
<section className="container">
<div className="section search">
<h1 className="title">Search</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form
onSubmit={onSubmit}
onSubmit={getCVSearchResults}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<div className="flex flex-row w-full">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
<div className="w-10 text-gray-400">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<button
className="sm:mt-0 rounded-r-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"
type="submit"
>
<form onSubmit={handleSubmit} className="form columns search">
<div className="column is-three-quarters search">
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="input main-search-bar is-large"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<div className="column">
<button type="submit" className="button is-medium">
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>
{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 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 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.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
{result.cover_date && (
<p>
<span className="tag is-light">Cover date</span>
{dayjs(result.cover_date).format("MMM D, YYYY")}
</p>
)}
<p className="tag is-warning">{result.id}</p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<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>
) : (
result.resource_type === "volume" && (
{!isNil(comicVineSearchResults.results) &&
!isEmpty(comicVineSearchResults.results) ? (
<div className="columns is-multiline">
{comicVineSearchResults.results.map((result) => {
return (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
className="comicvine-metadata column is-10 mb-3"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<div className="columns">
<div className="column is-one-quarter">
<Card
key={result.id}
orientation={"cover-only"}
orientation={"vertical"}
imageUrl={result.image.small_url}
title={result.name}
hasDetails={false}
/>
></Card>
</div>
<div className="w-3/4">
<div className="text-xl">
<div className="column">
<div className="is-size-3">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
<span className="is-size-3">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>
<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>
)}
{/* 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>
</div>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
</div>
)}
</>
)}
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning">
{result.id}
</span>
</div>
</div>
</div>
<span className="tag is-warning">{result.id}</span>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<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"],
selectors: ["p"],
},
}),
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>
<button
className="button is-success is-light is-outlined mt-2"
onClick={() => addToLibrary("comicvine", result)}
>
<i className="fa-solid fa-plus mr-2"></i> Want
</button>
</div>
</div>
</div>
)
);
})}
</div>
) : (
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<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"
>
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
);
})}
</div>
) : (
<article className="message is-dark is-half">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Search the ComicVine database
</span>
Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or
torrent clients.
</p>
</div>
</article>
</div>
)}
)}
</div>
</section>
</div>
</>
);
};

View File

@@ -1,20 +1,27 @@
import React, { ReactElement, useState } from "react";
import React, { ReactElement, useEffect, useState, useContext } 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 () =>
@@ -22,31 +29,23 @@ 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 axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
let hubList: any[] = [];
let hubList = {};
if (!isNil(hubs)) {
hubList = hubs?.data.map(({ hub_url, identity }) => ({
hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
const mutation = useMutation({
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
@@ -57,112 +56,79 @@ export const AirDCPPHubsForm = (): ReactElement => {
settingsKey: "directConnect",
},
}),
onSuccess: (data) => {
queryClient.setQueryData(["settings"], (oldData: any) =>
produce(oldData, (draft: any) => {
draft.data.directConnect.client = {
...draft.data.directConnect.client,
...data.data.directConnect.client,
};
}),
);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
const validate = async (values) => {
const errors = {};
// Add any validation logic here if needed
return errors;
};
const validate = async () => {};
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={(values) => {
mutation.mutate(values);
}}
onSubmit={mutate}
validate={validate}
render={({ handleSubmit }) => (
<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"
>
<form onSubmit={handleSubmit}>
<div>
<h3 className="title">Hubs</h3>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against. Your
selection in the dropdown <strong>will replace</strong> the
existing selection.
Select the hubs you want to perform searches against.
</h6>
</article>
<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"
>
<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>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
)}
/>
) : (
<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>
<>
<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>
</>
)}
{!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"></div>
<div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article>
</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 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>
</>
) : null}

View File

@@ -1,64 +1,76 @@
import React, { useState, useEffect } from "react";
import React, { ReactElement, useCallback } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { isUndefined, isEmpty } from "lodash";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
import { useShallow } from "zustand/react/shallow";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import {
AIRDCPP_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
} from "../../../constants/endpoints";
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`),
});
// 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: (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(() =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
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,
})),
);
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
/**
* Mutation to update settings and subsequently initialize
* AirDC++ socket with those settings
*/
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,
});
},
});
const deleteSettingsMutation = useMutation(
async () =>
await axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
// const removeSettings = useCallback(async () => {
// // airDCPPSettings.setSettings({});
// }, []);
//
const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration
: {};
return (
<>
<ConnectionForm
@@ -67,12 +79,13 @@ export const AirDCPPSettingsForm = () => {
formHeading={"Configure AirDC++"}
/>
{airDCPPSessionInformation && (
{!isEmpty(airDCPPSessionInformation) ? (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
)}
) : null}
{settingsData?.data && (
{!isEmpty(airDCPPClientConfiguration) ? (
<p className="control mt-4">
as
<button
className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()}
@@ -80,7 +93,7 @@ export const AirDCPPSettingsForm = () => {
Delete
</button>
</p>
)}
) : null}
</>
);
};

View File

@@ -1,37 +0,0 @@
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;

View File

@@ -1,61 +0,0 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Form, Field } from "react-final-form";
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
import axios from "axios";
export const ProwlarrSettingsForm = (props) => {
const { data } = useQuery({
queryFn: async (): any => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
method: "POST",
data: {
host: "localhost",
port: "9696",
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
},
});
},
queryKey: ["prowlarrConnectionResult"],
});
const submitHandler = () => {};
const initialData = {};
return (
<>
Prowlarr Settings.
<Form
onSubmit={submitHandler}
initialValues={initialData}
render={({ handleSubmit }) => (
<form>
<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"
>
<div>
<p>Configure Prowlarr integration here.</p>
<p>
Note that you need a Prowlarr instance hosted and running to
configure the integration.
</p>
<p>
See{" "}
<a
className="underline"
href="http://airdcpp.net/docs/installation/installation.html"
>
here
</a>{" "}
for Prowlarr installation instructions for various platforms.
</p>
</div>
</article>
</form>
)}
/>
</>
);
};
export default ProwlarrSettingsForm;

View File

@@ -14,9 +14,19 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "GET",
}),
});
console.log(data);
const hostDetails = data?.data?.bittorrent?.client?.host;
// connect to qbittorrent client
const { data: connectionDetails } = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/connect",
method: "POST",
data: hostDetails,
}),
enabled: !!hostDetails,
});
// get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"],
@@ -25,6 +35,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET",
}),
enabled: !!connectionDetails,
});
// Update action using a mutation
const { mutate } = useMutation({

View File

@@ -3,8 +3,6 @@ import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
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";
@@ -13,130 +11,128 @@ interface ISettingsProps {}
export const Settings = (props: ISettingsProps): ReactElement => {
const [active, setActive] = useState("gen-db");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpanded = (id: string) => {
setExpanded((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const settingsContent = [
{ 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 /> },
{
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: "core-service",
content: <>a</>,
},
{
id: "flushdb",
content: (
<div key="flushdb">
<SystemSettingsForm />
</div>
),
},
];
return (
<div>
<section>
{/* Header */}
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-6 sm:px-6 lg:px-8">
<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 sm:text-3xl font-bold text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Settings
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-white">
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
<div className="flex flex-cols max-w-screen-xl mx-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => {
return (
<div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
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())}
>
{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">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
{/* 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
key={idx}
className="mb-6 text-slate-700 dark:text-slate-300"
>
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
{settingObject.category.toUpperCase()}
</h3>
{!isUndefined(settingObject.children) && (
<ul>
{map(settingObject.children, (item, idx) => {
const isOpen = expanded[item.id];
return (
<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"
: ""
}`}
>
<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
onClick={() =>
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"
: ""
}`}
>
{subItem.displayName}
</a>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</aside>
{/* content for settings */}
<div className="max-w-screen-xl">
<div className="content">
{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>

View File

@@ -48,11 +48,13 @@ export const SystemSettingsForm = (): ReactElement => {
</article>
<button
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"
className={
isLoading ? "button is-danger is-loading" : "button is-danger"
}
onClick={() => flushDb()}
>
<span className="pt-1 px-1">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
<span className="icon">
<i className="fas fa-eraser"></i>
</span>
<span>Flush DB & Temporary Folders</span>
</button>

View File

@@ -1,25 +1,30 @@
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
import React, { ReactElement, useState, useCallback } from "react";
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
import React, { useEffect, ReactElement, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router";
import { analyzeLibrary } from "../../actions/comicinfo.actions";
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
import {
getComicBookDetailById,
getIssuesForSeries,
analyzeLibrary,
} from "../../actions/comicinfo.actions";
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
@@ -28,9 +33,7 @@ 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} />;
},
},
};
@@ -42,145 +45,68 @@ const VolumeDetails = (props): ReactElement => {
setVisible(true);
}, []);
// const analyzeIssues = useCallback((issues) => {
// dispatch(analyzeLibrary(issues));
// }, []);
//
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 { 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(issuesForSeries) ? (
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
{!isUndefined(issuesForVolume) ? (
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
Analyze Library
</div>
) : null}
<>
{isSuccess &&
issuesForSeries.data.map((issue) => {
return (
<>
<Masonry
breakpointCols={breakpointColumnsObj}
className="issues-container"
columnClassName="issues-column"
>
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
? issuesForVolume.map((issue) => {
return (
<Card
key={issue.id}
imageUrl={issue.image.small_url}
orientation={"cover-only"}
hasDetails={false}
/>
<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}
</>
);
})}
</>
</>
);
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>
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>
</>
);
@@ -189,44 +115,20 @@ const VolumeDetails = (props): ReactElement => {
{
id: 1,
name: "Issues in Volume",
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
content: <Issues />,
icon: <i className="fa-solid fa-layer-group"></i>,
content: <IssuesInVolume key={1} />,
},
{
id: 2,
icon: (
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
),
icon: <i className="fa-regular fa-mask"></i>,
name: "Characters",
content: <div key={2}>Characters</div>,
content: <div key={2}>asdasd</div>,
},
{
id: 3,
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>
),
icon: <i className="fa-solid fa-scroll"></i>,
name: "Arcs",
content: <div key={3}>asdasd</div>,
},
];
@@ -234,26 +136,21 @@ const VolumeDetails = (props): ReactElement => {
const MetadataTabGroup = () => {
return (
<>
<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>
<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>
{name}
</a>
))}
</nav>
</div>
</li>
))}
</ul>
</div>
{tabGroup.map(({ id, content }) => {
return active === id ? content : null;
@@ -261,103 +158,97 @@ const VolumeDetails = (props): ReactElement => {
</>
);
};
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
const { sourcedMetadata } = comicObject.data;
return (
<>
<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 */}
if (
!isUndefined(comicBookDetails.sourcedMetadata) &&
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
) {
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">
<Card
imageUrl={
sourcedMetadata.comicvine.volumeInformation.image.small_url
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.image.small_url
}
orientation={"cover-only"}
cardContainerStyle={{ maxWidth: 275 }}
orientation={"vertical"}
hasDetails={false}
/>
</div>
<div>
<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">
{
sourcedMetadata.comicvine.volumeInformation.publisher
.name
}
</span>
</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>
{/* Deck */}
<div>
{!isEmpty(
sourcedMetadata.comicvine.volumeInformation.description,
)
? ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation
.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
{/* 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>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
{/* Deck */}
<div>
{!isEmpty(
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.description,
)
? ellipsize(
convert(
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div>
</div>
<MetadataTabGroup />
</div>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
</div>
<MetadataTabGroup />
</div>
</>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
);
} else {
return <></>;

View File

@@ -4,85 +4,124 @@ 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";
import { isUndefined } from "lodash";
export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const {
data: volumes,
isSuccess,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
pagination: {
size: 25,
from: 0,
},
type: "volumes",
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
useEffect(() => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumes",
// trigger: "volumesPage",
// },
// ),
// );
}, []);
const columnData = useMemo(
(): any => [
() => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorFn: (row) => row,
cell: (row): any => {
const comicObject = row.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
accessorKey: "_source",
cell: (row) => {
const foo = row.getValue();
return (
<div className="flex flex-row gap-3 mt-5">
<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 className="columns">
<div className="column">
<div className="comic-detail issue-metadata">
<dl>
<dd>
<div className="columns mt-2">
<div className="">
<Card
imageUrl={
foo.sourcedMetadata.comicvine.volumeInformation
.image.thumb_url
}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
<p>
{ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
180,
)}
</p>
</div>
</div>
);
},
},
{
header: "Other Details",
header: "Download Status",
columns: [
{
header: "Downloads",
header: "Files",
accessorKey: "_source.acquisition.directconnect",
align: "right",
cell: (props) => {
@@ -103,34 +142,12 @@ export const Volumes = (props): ReactElement => {
},
},
{
header: "Publisher",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
header: "Type",
id: "Air",
},
{
header: "Issue Count",
accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
const row = props.getValue();
return (
<div className="mt-5">
{/* issue count */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 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-6 h-6"></i>
</span>
<span className="text-lg text-slate-500 dark:text-slate-900">
{row}
</span>
</span>
</div>
);
},
header: "Type",
id: "dcc",
},
],
},
@@ -138,45 +155,28 @@ export const Volumes = (props): ReactElement => {
[],
);
return (
<div>
<section className="">
<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>
{isSuccess ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Volumes</h1>
</div>
{!isUndefined(volumes.hits) && (
<div>
<div className="library">
<T2Table
sourceData={volumes?.data.hits.hits}
totalPages={volumes?.data.hits.hits.length}
sourceData={volumes?.hits}
totalPages={volumes.hits.length}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
}}
rowClickHandler={() => {}}
columns={columnData}
/>
</div>
</div>
) : null}
{isError ? (
<div>An error was encountered while retrieving volumes</div>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
)}
</div>
</section>
);
};

View File

@@ -1,37 +1,32 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table";
import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const WantedComics = (props): ReactElement => {
const {
data: wantedComics,
isSuccess,
isFetched,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
useEffect(() => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "wanted",
// trigger: "wantedComicsPage"
// },
// ),
// );
}, []);
pagination: {
size: 25,
from: 0,
},
type: "wanted",
trigger: "wantedComicsPage",
},
}),
queryKey: ["wantedComics"],
enabled: true,
});
const columnData = [
{
header: "Comic Information",
@@ -41,10 +36,7 @@ export const WantedComics = (props): ReactElement => {
id: "comicDetails",
minWidth: 350,
accessorFn: (data) => data,
cell: (value) => {
const row = value.getValue()._source;
return row && <MetadataPanel data={row} />;
},
cell: (value) => <MetadataPanel data={value.getValue()} />,
},
],
},
@@ -53,8 +45,8 @@ export const WantedComics = (props): ReactElement => {
columns: [
{
header: "Files",
accessorKey: "acquisition",
align: "right",
accessorKey: "_source.acquisition",
cell: (props) => {
const {
directconnect: { downloads },
@@ -77,7 +69,7 @@ export const WantedComics = (props): ReactElement => {
{
header: "Download Details",
id: "downloadDetails",
accessorKey: "_source.acquisition",
accessorKey: "acquisition",
cell: (data) => (
<ol>
{data.getValue().directconnect.downloads.map((download, idx) => {
@@ -106,23 +98,23 @@ export const WantedComics = (props): ReactElement => {
* @returns void
*
**/
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from: pageSize * pageIndex + 1,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "wanted",
trigger: "wantedComicsPage",
},
),
);
}, []);
/**
* Pagination control that fetches the previous x (pageSize) items
@@ -131,71 +123,55 @@ export const WantedComics = (props): ReactElement => {
* @param {number} pageSize
* @returns void
**/
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
// let from = 0;
// if (pageIndex === 2) {
// from = (pageIndex - 1) * pageSize + 2 - 17;
// } else {
// from = (pageIndex - 1) * pageSize + 2 - 16;
// }
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17;
} else {
from = (pageIndex - 1) * pageSize + 2 - 16;
}
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from,
},
type: "wanted",
trigger: "wantedComicsPage",
},
),
);
}, []);
return (
<div className="">
<section className="">
<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">
Wanted Comics
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse through comics you marked as "wanted."
</p>
</div>
</div>
</div>
</header>
{isSuccess && wantedComics?.data.hits?.hits ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Wanted Comics</h1>
</div>
{!isEmpty(wantedComics) && (
<div>
<div className="library">
<T2Table
sourceData={wantedComics?.data.hits.hits}
totalPages={wantedComics?.data.hits.hits.length}
sourceData={wantedComics}
totalPages={wantedComics.length}
columns={columnData}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
nextPage: nextPage,
previousPage: previousPage,
}}
// rowClickHandler={navigateToComicDetail}
/>
{/* pagination controls */}
</div>
</div>
) : null}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</section>
</div>
)}
</div>
</section>
);
};

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef } from "react";
export const Canvas = ({ data }) => {
const { colorHistogramData } = data;
export const Canvas = (data) => {
const { colorHistogramData } = data.data;
console.log(data);
const width = 559;
const height = 200;
const pixelRatio = window.devicePixelRatio;
@@ -9,11 +10,7 @@ export const Canvas = ({ data }) => {
const canvas = useRef(null);
useEffect(() => {
const context = canvas.current?.getContext("2d");
if (!context) {
return;
}
const context = canvas.current.getContext("2d");
context.scale(pixelRatio, pixelRatio);
const guideHeight = 8;
const startY = height - guideHeight;
@@ -49,24 +46,18 @@ export const Canvas = ({ data }) => {
context.stroke();
// Guide
context.strokeStyle = `rgb(${i}, ${i}, ${i})`;
context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, height);
context.closePath();
context.stroke();
}
// Cleanup function
return () => {
// Perform cleanup actions here
};
}, [colorHistogramData, pixelRatio]);
});
const dw = Math.floor(pixelRatio * width);
const dh = Math.floor(pixelRatio * height);
const style = { width, height };
return <canvas ref={canvas} width={dw} height={dh} style={style} />;
};

View File

@@ -0,0 +1,70 @@
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;

View File

@@ -10,31 +10,11 @@ interface ICardProps {
children?: PropTypes.ReactNodeLike;
borderColorClass?: string;
backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: React.CSSProperties;
imageStyle?: React.CSSProperties;
cardContainerStyle?: PropTypes.object;
imageStyle?: PropTypes.object;
}
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":
@@ -103,44 +83,31 @@ const renderCard = (props: ICardProps): ReactElement => {
case "vertical-2":
return (
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
<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 className="block rounded-md w-fit 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="mt-2 px-2">
<dl>
<div>
<dd className="text-md text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
)}
</dl>
{props.hasDetails && <>{props.children}</>}
</div>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);
case "horizontal-small":
return (
<>
<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"}`}>
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} className="object-cover h-20 w-20" />
@@ -156,7 +123,7 @@ const renderCard = (props: ICardProps): ReactElement => {
case "horizontal-medium":
return (
<>
<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"}`}>
<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">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} />

View File

@@ -1,98 +0,0 @@
import React, { useRef, useState } from "react";
import { format } from "date-fns";
import FocusTrap from "focus-trap-react";
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 classNames: ClassNames = {
...styles,
head: "custom-head",
};
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();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
if (refs.reference.current && refs.floating.current) {
autoUpdate(refs.reference.current, refs.floating.current, update);
}
};
const handleDaySelect = (date) => {
setSelected(date);
if (date) {
setter(format(date, "yyyy/MM/dd"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={reference}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
onClick={handleButtonClick}
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"
>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
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"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
classNames={classNames}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

@@ -64,7 +64,7 @@ export const DnD = (data) => {
className="mt-2 mb-2"
onClick={(e) => data.onClickHandler(url)}
>
<div className="box p-2 control-palette">
<div className="box p-2 pl-3 control-palette">
<span className="tag is-warning mr-2">{index}</span>
<span className="icon is-small mr-2">
<i className="fa-solid fa-vial"></i>

View File

@@ -8,6 +8,7 @@ export function Grid({ children, columns }) {
gridTemplateColumns: `repeat(${columns}, 200px)`,
columnGap: 1,
gridGap: 10,
padding: 10,
}}
>
{children}

View File

@@ -1,32 +1,19 @@
import React, { ReactElement } from "react";
import { Link } from "react-router-dom";
type IHeaderProps = {
headerContent: string;
subHeaderContent: ReactElement | string;
subHeaderContent: string;
iconClassNames: string;
link?: string;
};
export const Header = (props: IHeaderProps): ReactElement => {
return (
<div className="mt-7">
<div className="">
{props.link ? (
<Link to={props.link}>
<span className="text-xl">
<span className="underline">
{props.headerContent}{" "}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
</Link>
) : (
<div className="text-xl">{props.headerContent}</div>
)}
<p className="">{props.subHeaderContent}</p>
</div>
</div>
<>
<h4 className="title is-4">
<i className={props.iconClassNames}></i> {props.headerContent}
</h4>
<p className="subtitle is-7">{props.subHeaderContent}</p>
</>
);
};

View File

@@ -8,17 +8,14 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash";
interface IMetadatPanelProps {
data: any;
value?: any;
children?: any;
imageStyle?: any;
titleStyle?: any;
tagsStyle?: any;
containerStyle?: any;
isMissing?: boolean;
value: any;
children: any;
imageStyle: any;
titleStyle: any;
tagsStyle: any;
containerStyle: any;
}
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const { isMissing = false } = props;
const {
rawFileDetails,
inferredMetadata,
@@ -34,13 +31,11 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{
name: "rawFileDetails",
content: () => (
<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>
<dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg">
<dt>
<p className="text-lg">{issueName}</p>
</dt>
<dd className="text-xs sm:text-sm">
<dd className="text-sm">
is a part of{" "}
<span className="underline">
{inferredMetadata.issue.name}
@@ -55,52 +50,34 @@ 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-xs sm:text-md text-slate-900 dark:text-slate-900">
<span className="text-md text-slate-900 dark:text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
</dd>
)}
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
<dd className="flex flex-row gap-2 w-max">
{/* File extension */}
{rawFileDetails.mimeType && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="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 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">
{rawFileDetails.mimeType}
</span>
</span>
{/* size */}
{rawFileDetails.fileSize != null && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="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 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>
</span>
)}
{/* 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 className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
)}
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
<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--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span>
)}
</span>
</dd>
</dl>
),
@@ -128,6 +105,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span>
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
@@ -140,13 +118,42 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<span className="my-3 mx-2">
{comicvine.volumeInformation.start_year}
</span>
{comicvine.volumeInformation.count_of_issues}
ComicVine ID
{comicvine.id}
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{comicvine.volumeInformation.start_year}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{comicvine.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
</dd>
</dl>
),
@@ -191,17 +198,16 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const metadataPanel = find(metadataContentPanel, {
name: objectReference,
});
return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
<Card
imageUrl={url}
orientation={"cover-only"}
hasDetails={false}
imageStyle={props.imageStyle}
/>
</div>
<div className="flex-1">{metadataPanel?.content()}</div>
<div className="flex gap-5 my-3">
<Card
imageUrl={url}
orientation={"cover-only"}
hasDetails={false}
imageStyle={props.imageStyle}
/>
<div>{metadataPanel.content()}</div>
</div>
);
};

View File

@@ -0,0 +1,312 @@
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;

View File

@@ -3,22 +3,22 @@ import { Link } from "react-router-dom";
import { useDarkMode } from "../../hooks/useDarkMode";
export const Navbar2 = (): ReactElement => {
const [theme, setTheme] = useDarkMode();
const darkMode = theme === "dark";
const [colorTheme, setTheme] = useDarkMode();
const [darkMode, setDarkMode] = useState(false);
const toggleDarkMode = () => {
setTheme(darkMode ? "light" : "dark");
const toggleDarkMode = (checked) => {
setTheme(colorTheme);
setDarkMode(!darkMode);
};
return (
<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!" />
<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!" />
{/* 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"
@@ -52,12 +52,12 @@ export const Navbar2 = (): ReactElement => {
</li>
<li>
<Link
to="/volumes"
<a
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Volumes
</Link>
</a>
</li>
<li>
@@ -68,60 +68,51 @@ export const Navbar2 = (): ReactElement => {
Downloads
</a>
</li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</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>
{/* 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"
>
<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>
<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>

View File

@@ -1,45 +0,0 @@
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;

View File

@@ -1,89 +1,60 @@
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import {
ColumnDef,
Row,
flexRender,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
PaginationState,
} from "@tanstack/react-table";
/** 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);
}, []);
export const T2Table = (tableOptions): ReactElement => {
const {
sourceData,
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
} = tableOptions;
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
/** Advances to the next page and notifies the parent. */
/**
* Pagination control to move forward one page
* @returns void
*/
const goToNextPage = () => {
setPagination({ pageIndex: pageIndex + 1, pageSize });
nextPage?.(pageIndex, pageSize);
setPagination({
pageIndex: pageIndex + 1,
pageSize,
});
nextPage(pageIndex, pageSize);
};
/** Goes back one page and notifies the parent. */
/**
* Pagination control to move backward one page
* @returns void
**/
const goToPreviousPage = () => {
setPagination({ pageIndex: pageIndex - 1, pageSize });
previousPage?.(pageIndex, pageSize);
setPagination({
pageIndex: pageIndex - 1,
pageSize,
});
previousPage(pageIndex, pageSize);
};
const table = useReactTable({
@@ -92,62 +63,59 @@ export const T2Table = <TData,>({
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1,
state: { pagination },
state: {
pagination,
},
onPaginationChange: setPagination,
});
return (
<div className="container max-w-fit">
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{children}
<div className="container max-w-fit mx-14">
<div>
{/* Search bar */}
<div className="flex flex-row gap-2 justify-between mt-5">
<SearchBar />
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
{/* pagination controls */}
<div>
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={goToPreviousPage}
disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
>
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
</button>
<button
onClick={goToNextPage}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
</button>
<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>
</div>
</div>
<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) => (
<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) => (
<th
key={header.id}
colSpan={header.colSpan}
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(' ')}
className="px-3 py-3"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
@@ -155,23 +123,36 @@ export const T2Table = <TData,>({
</thead>
<tbody>
{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>
))}
{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>
);
})}
</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,
};
export default T2Table;

View File

@@ -0,0 +1,48 @@
export const comicModel = {
name: "",
type: "",
import: {
isImported: false,
},
userAddedMetadata: {
tags: [],
},
comicStructure: {
cover: {
thumb: "http://thumb",
medium: "http://medium",
large: "http://large",
},
collection: {
publishDate: "",
type: "", // issue, series, trade paperback
metadata: {
publisher: "",
issueNumber: "",
description: "",
synopsis: "",
team: {},
},
},
},
sourcedMetadata: {
comicvine: {},
shortboxed: {},
gcd: {},
},
rawFileDetails: {
fileName: "",
path: "",
extension: "",
},
acquisition: {
release: {},
torrent: {
magnet: "",
tracker: "",
status: "",
},
usenet: {},
},
};

View File

@@ -8,6 +8,7 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
options.apiPath
);
};
console.log(import.meta);
export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http",
@@ -90,24 +91,3 @@ export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
port: "3060",
apiPath: `/api/qbittorrent`,
});
export const PROWLARR_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/prowlarr`,
});
export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
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`,
});

View File

@@ -0,0 +1 @@
export const SUPPORTED_COMIC_ARCHIVES = [".cbz", ".cbr"];

View File

@@ -9,8 +9,8 @@
"displayName": "Dashboard"
},
{
"id": "gen-docker-vars",
"displayName": "Docker ENV vars"
"id": "gen-gls",
"displayName": "Global Search"
}
]
},
@@ -57,7 +57,7 @@
"displayName": "Prowlarr",
"children": [
{
"id": "prwlr-connection",
"id": "prowlarr-connection",
"displayName": "Connection"
},
{

View File

@@ -0,0 +1,11 @@
import React, { createContext } from "react";
export const SocketIOContext = createContext({});
export const SocketIOProvider = ({ children, socket }) => {
return (
<SocketIOContext.Provider value={socket}>
{children}
</SocketIOContext.Provider>
);
};

View File

@@ -1,76 +0,0 @@
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
}

View File

@@ -1,43 +0,0 @@
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');
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,191 +0,0 @@
query GetComicById($id: ID!) {
comic(id: $id) {
id
# Inferred metadata
inferredMetadata {
issue {
name
number
year
subtitle
}
}
# Canonical metadata
canonicalMetadata {
title {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
series {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
volume {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
issueNumber {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
publisher {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
publicationDate {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
coverDate {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
description {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
creators {
name
role
provenance {
source
sourceId
confidence
fetchedAt
url
}
}
pageCount {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
coverImage {
value
provenance {
source
sourceId
confidence
fetchedAt
url
}
userOverride
}
}
# 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
}
}

View File

@@ -1,222 +0,0 @@
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
}
}
}

View File

@@ -1,61 +0,0 @@
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
}
}
}

View File

@@ -1,72 +0,0 @@
# 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
}
}
}

Some files were not shown because too many files have changed in this diff Show More