Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0949ebc637 | |||
| 3e045f4c10 | |||
| 17db1e64e1 | |||
| d7ab553120 | |||
| 91592019c4 | |||
| 0e8f63101c | |||
| 4e2cad790b | |||
| ba1b5bb965 | |||
| 8546641152 | |||
| 867935be39 | |||
| d506cf8ba8 | |||
| 71d7034d01 | |||
| a217d447fa | |||
| 20336e5569 | |||
| 8913e9cd99 | |||
| c392333170 | |||
| e083c18c0e | |||
| 7818c6f290 | |||
| 46e683859e | |||
| a45eae2604 | |||
| a0d971e010 | |||
| aec989d021 | |||
| 2b4ee716e3 | |||
| ec52906eca | |||
| 07f5e6efe6 | |||
| 5d18bd1e43 | |||
| 74c0d6513c | |||
| 4b8d7b5905 | |||
| 92992449a9 | |||
| 59afeded6a | |||
| f9aac5e19f | |||
| a8ae4130a6 | |||
| 6f781af381 | |||
| c005d118ac | |||
| 4498830e29 | |||
| e113066094 | |||
| 0af9482be9 | |||
| 37a2d0c75b | |||
| b47b38cc8d | |||
|
|
5c99cfb28b | ||
|
|
880b6c44ff | ||
| b0c8c295c7 | |||
| d39e2cdca1 | |||
|
|
1a6154e0b4 | ||
|
|
d8e110e2e7 | ||
|
|
9189386593 | ||
| a10cb07d67 | |||
|
|
2a83855115 | ||
|
|
58b60a38b4 | ||
|
|
c339dc9df1 | ||
|
|
d8bcad88b7 | ||
|
|
ff1742998a | ||
|
|
de1fb349f6 | ||
|
|
698e2a89da | ||
|
|
0597180637 | ||
|
|
422b16abf8 | ||
| 80f0ced0b0 | |||
|
|
0def39cd73 | ||
|
|
1c60837ec9 | ||
|
|
d42a700cec | ||
|
|
40fd8ede5d | ||
|
|
24b09c9c5e | ||
| c176dab78b | |||
| c0b189c9e6 | |||
| f3333b5c2c | |||
| 2ce90d94c0 | |||
| 0e445ba3d4 | |||
| 9c5fb93d5b | |||
| 338a46224d | |||
|
|
0615d08e7d | ||
| 21a127509b | |||
|
|
e92b86792e | ||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 | |||
|
|
f57bd35cd4 | ||
|
|
56c9fb03ac | ||
|
|
26c9c1e562 | ||
|
|
51e18c65d1 | ||
|
|
b82d5fd350 | ||
| 9a3ccba719 | |||
|
|
c89e4af328 | ||
| df4cecedf0 | |||
| 3dee53f33f | |||
| ef05dee600 | |||
| 2b6bce4731 | |||
| fc4c5c61e2 | |||
| ae04260f69 | |||
|
|
ad5fc0b8b3 | ||
| c03f706e9d | |||
| 4f49e538a8 | |||
|
|
68442894d0 | ||
| dba520b4c1 | |||
| ef75dad4e2 | |||
| 8bebffd95e | |||
|
|
1bd3d611e4 | ||
|
|
825782fe13 | ||
| 32f4055daa | |||
|
|
c20f24b1a2 | ||
|
|
e4fc28f698 | ||
|
|
d6c183339f | ||
|
|
0eba47e20f | ||
| 928bfd573e | |||
| b8fb179ac6 | |||
| 94692bb6d4 | |||
| 4b795aca5d | |||
| fbf6bed4fe | |||
|
|
18b18c3d81 | ||
| a939bf4c71 | |||
| 0a48ecbb2c | |||
| c5dd1abcdd | |||
| 79f9b22fad | |||
|
|
e291b4806b | ||
| ff7b7c9207 | |||
| 2d61fa1436 | |||
| b12849efda | |||
| fb2c969c00 |
17
.babelrc
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
"@babel/preset-env",
|
|
||||||
"@babel/preset-react",
|
|
||||||
"@babel/preset-typescript"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"@babel/transform-runtime",
|
|
||||||
"@babel/plugin-proposal-class-properties",
|
|
||||||
"@babel/plugin-syntax-top-level-await"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"production": {
|
|
||||||
"presets": ["minify"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
379
.claude/skills/jsdoc/SKILL.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
---
|
||||||
|
name: jsdoc
|
||||||
|
description: Commenting and documentation guidelines. Auto-activate when the user discusses comments, documentation, docstrings, code clarity, API docs, JSDoc, or asks about commenting strategies.
|
||||||
|
---
|
||||||
|
|
||||||
|
Auto-activate when: User discusses comments, documentation, docstrings, code clarity, code quality, API docs, JSDoc, Python docstrings, or asks about commenting strategies.
|
||||||
|
Core Principle
|
||||||
|
|
||||||
|
Write code that speaks for itself. Comment only when necessary to explain WHY, not WHAT.
|
||||||
|
|
||||||
|
Most code does not need comments. Well-written code with clear naming and structure is self-documenting.
|
||||||
|
|
||||||
|
The best comment is the one you don't need to write because the code is already obvious.
|
||||||
|
The Commenting Philosophy
|
||||||
|
When to Comment
|
||||||
|
|
||||||
|
✅ DO comment when explaining:
|
||||||
|
|
||||||
|
WHY something is done (business logic, design decisions)
|
||||||
|
Complex algorithms and their reasoning
|
||||||
|
Non-obvious trade-offs or constraints
|
||||||
|
Workarounds for bugs or limitations
|
||||||
|
API contracts and public interfaces
|
||||||
|
Regex patterns and what they match
|
||||||
|
Performance considerations or optimizations
|
||||||
|
Constants and magic numbers
|
||||||
|
Gotchas or surprising behaviors
|
||||||
|
|
||||||
|
❌ DON'T comment when:
|
||||||
|
|
||||||
|
The code is obvious and self-explanatory
|
||||||
|
The comment repeats the code (redundant)
|
||||||
|
Better naming would eliminate the need
|
||||||
|
The comment would become outdated quickly
|
||||||
|
It's decorative or organizational noise
|
||||||
|
It states what a standard language construct does
|
||||||
|
|
||||||
|
Comment Anti-Patterns
|
||||||
|
❌ 1. Obvious Comments
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
|
||||||
|
counter = 0 # Initialize counter to zero
|
||||||
|
counter += 1 # Increment counter by one
|
||||||
|
user_name = input("Enter name: ") # Get user name from input
|
||||||
|
|
||||||
|
Better: No comment needed - the code is self-explanatory.
|
||||||
|
❌ 2. Redundant Comments
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
|
||||||
|
def get_user_name(user):
|
||||||
|
return user.name # Return the user's name
|
||||||
|
|
||||||
|
def calculate_total(items):
|
||||||
|
# Loop through items and sum the prices
|
||||||
|
total = 0
|
||||||
|
for item in items:
|
||||||
|
total += item.price
|
||||||
|
return total
|
||||||
|
|
||||||
|
Better:
|
||||||
|
|
||||||
|
def get_user_name(user):
|
||||||
|
return user.name
|
||||||
|
|
||||||
|
def calculate_total(items):
|
||||||
|
return sum(item.price for item in items)
|
||||||
|
|
||||||
|
❌ 3. Outdated Comments
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
|
||||||
|
# Calculate tax at 5% rate
|
||||||
|
tax = price * 0.08 # Actually 8%, comment is wrong
|
||||||
|
|
||||||
|
# DEPRECATED: Use new_api_function() instead
|
||||||
|
def old_function(): # Still being used, comment is misleading
|
||||||
|
pass
|
||||||
|
|
||||||
|
Better: Keep comments in sync with code, or remove them entirely.
|
||||||
|
❌ 4. Noise Comments
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
|
||||||
|
# Start of function
|
||||||
|
def calculate():
|
||||||
|
# Declare variable
|
||||||
|
result = 0
|
||||||
|
# Return result
|
||||||
|
return result
|
||||||
|
# End of function
|
||||||
|
|
||||||
|
Better: Remove all of these comments.
|
||||||
|
❌ 5. Dead Code & Changelog Comments
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
|
||||||
|
# Don't comment out code - use version control
|
||||||
|
# def old_function():
|
||||||
|
# return "deprecated"
|
||||||
|
|
||||||
|
# Don't maintain history in comments
|
||||||
|
# Modified by John on 2023-01-15
|
||||||
|
# Fixed bug reported by Sarah on 2023-02-03
|
||||||
|
|
||||||
|
Better: Delete the code. Git has the history.
|
||||||
|
Good Comment Examples
|
||||||
|
✅ Complex Business Logic
|
||||||
|
|
||||||
|
# Apply progressive tax brackets: 10% up to $10k, 20% above
|
||||||
|
# This matches IRS publication 501 for 2024
|
||||||
|
def calculate_progressive_tax(income):
|
||||||
|
if income <= 10000:
|
||||||
|
return income * 0.10
|
||||||
|
else:
|
||||||
|
return 1000 + (income - 10000) * 0.20
|
||||||
|
|
||||||
|
✅ Non-obvious Algorithms
|
||||||
|
|
||||||
|
# Using Floyd-Warshall for all-pairs shortest paths
|
||||||
|
# because we need distances between all nodes.
|
||||||
|
# Time: O(n³), Space: O(n²)
|
||||||
|
for k in range(vertices):
|
||||||
|
for i in range(vertices):
|
||||||
|
for j in range(vertices):
|
||||||
|
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
|
||||||
|
|
||||||
|
✅ Regex Patterns
|
||||||
|
|
||||||
|
# Match email format: username@domain.extension
|
||||||
|
# Allows letters, numbers, dots, hyphens in username
|
||||||
|
# Requires valid domain and 2+ char extension
|
||||||
|
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
|
||||||
|
✅ API Constraints or Gotchas
|
||||||
|
|
||||||
|
# GitHub API rate limit: 5000 requests/hour for authenticated users
|
||||||
|
# We implement exponential backoff to handle rate limiting
|
||||||
|
await rate_limiter.wait()
|
||||||
|
response = await fetch(github_api_url)
|
||||||
|
|
||||||
|
✅ Workarounds for Bugs
|
||||||
|
|
||||||
|
# HACK: Workaround for bug in library v2.1.0
|
||||||
|
# Remove after upgrading to v2.2.0
|
||||||
|
# See: https://github.com/library/issues/123
|
||||||
|
if library_version == "2.1.0":
|
||||||
|
apply_workaround()
|
||||||
|
|
||||||
|
Decision Framework
|
||||||
|
|
||||||
|
Before writing a comment, ask yourself:
|
||||||
|
Step 1: Is the code self-explanatory?
|
||||||
|
|
||||||
|
If YES → No comment needed
|
||||||
|
If NO → Continue to step 2
|
||||||
|
|
||||||
|
Step 2: Would a better variable/function name eliminate the need?
|
||||||
|
|
||||||
|
If YES → Refactor the code instead
|
||||||
|
If NO → Continue to step 3
|
||||||
|
|
||||||
|
Step 3: Does this explain WHY, not WHAT?
|
||||||
|
|
||||||
|
If explaining WHAT → Refactor code to be clearer
|
||||||
|
If explaining WHY → Good comment candidate
|
||||||
|
|
||||||
|
Step 4: Will this help future maintainers?
|
||||||
|
|
||||||
|
If YES → Write the comment
|
||||||
|
If NO → Skip it
|
||||||
|
|
||||||
|
Special Cases for Comments
|
||||||
|
Public APIs and Docstrings
|
||||||
|
Python Docstrings
|
||||||
|
|
||||||
|
def calculate_compound_interest(
|
||||||
|
principal: float,
|
||||||
|
rate: float,
|
||||||
|
time: int,
|
||||||
|
compound_frequency: int = 1
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate compound interest using the standard formula.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
principal: Initial amount invested
|
||||||
|
rate: Annual interest rate as decimal (e.g., 0.05 for 5%)
|
||||||
|
time: Time period in years
|
||||||
|
compound_frequency: Times per year interest compounds (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final amount after compound interest
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any parameter is negative
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> calculate_compound_interest(1000, 0.05, 10)
|
||||||
|
1628.89
|
||||||
|
"""
|
||||||
|
if principal < 0 or rate < 0 or time < 0:
|
||||||
|
raise ValueError("Parameters must be non-negative")
|
||||||
|
|
||||||
|
# Compound interest formula: A = P(1 + r/n)^(nt)
|
||||||
|
return principal * (1 + rate / compound_frequency) ** (compound_frequency * time)
|
||||||
|
|
||||||
|
JavaScript/TypeScript JSDoc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user data from the API.
|
||||||
|
*
|
||||||
|
* @param {string} userId - The unique user identifier
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {boolean} options.includeProfile - Include profile data (default: true)
|
||||||
|
* @param {number} options.timeout - Request timeout in ms (default: 5000)
|
||||||
|
*
|
||||||
|
* @returns {Promise<User>} User object with requested fields
|
||||||
|
*
|
||||||
|
* @throws {Error} If userId is invalid or request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const user = await fetchUser('123', { includeProfile: true });
|
||||||
|
*/
|
||||||
|
async function fetchUser(userId, options = {}) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
Constants and Configuration
|
||||||
|
|
||||||
|
# Based on network reliability studies (95th percentile)
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
|
||||||
|
# AWS Lambda timeout is 15s, leaving 5s buffer for cleanup
|
||||||
|
API_TIMEOUT = 10000 # milliseconds
|
||||||
|
|
||||||
|
# Cache duration optimized for balance between freshness and load
|
||||||
|
# See: docs/performance-tuning.md
|
||||||
|
CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
Annotations for TODOs and Warnings
|
||||||
|
|
||||||
|
# TODO: Replace with proper authentication after security review
|
||||||
|
# Issue: #456
|
||||||
|
def temporary_auth(user):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# WARNING: This function modifies the original array instead of creating a copy
|
||||||
|
def sort_in_place(arr):
|
||||||
|
arr.sort()
|
||||||
|
return arr
|
||||||
|
|
||||||
|
# FIXME: Memory leak in production - investigate connection pooling
|
||||||
|
# Ticket: JIRA-789
|
||||||
|
def get_connection():
|
||||||
|
return create_connection()
|
||||||
|
|
||||||
|
# PERF: Consider caching this result if called frequently in hot path
|
||||||
|
def expensive_calculation(data):
|
||||||
|
return complex_algorithm(data)
|
||||||
|
|
||||||
|
# SECURITY: Validate input to prevent SQL injection before using in query
|
||||||
|
def build_query(user_input):
|
||||||
|
sanitized = escape_sql(user_input)
|
||||||
|
return f"SELECT * FROM users WHERE name = '{sanitized}'"
|
||||||
|
|
||||||
|
Common Annotation Keywords
|
||||||
|
|
||||||
|
TODO: - Work that needs to be done
|
||||||
|
FIXME: - Known bugs that need fixing
|
||||||
|
HACK: - Temporary workarounds
|
||||||
|
NOTE: - Important information or context
|
||||||
|
WARNING: - Critical information about usage
|
||||||
|
PERF: - Performance considerations
|
||||||
|
SECURITY: - Security-related notes
|
||||||
|
BUG: - Known bug documentation
|
||||||
|
REFACTOR: - Code that needs refactoring
|
||||||
|
DEPRECATED: - Soon-to-be-removed code
|
||||||
|
|
||||||
|
Refactoring Over Commenting
|
||||||
|
Instead of Commenting Complex Code...
|
||||||
|
|
||||||
|
BAD: Complex code with comment
|
||||||
|
|
||||||
|
# Check if user is admin or has special permissions
|
||||||
|
if user.role == "admin" or (user.permissions and "special" in user.permissions):
|
||||||
|
grant_access()
|
||||||
|
|
||||||
|
...Extract to Named Function
|
||||||
|
|
||||||
|
GOOD: Self-explanatory through naming
|
||||||
|
|
||||||
|
def user_has_admin_access(user):
|
||||||
|
return user.role == "admin" or has_special_permission(user)
|
||||||
|
|
||||||
|
def has_special_permission(user):
|
||||||
|
return user.permissions and "special" in user.permissions
|
||||||
|
|
||||||
|
if user_has_admin_access(user):
|
||||||
|
grant_access()
|
||||||
|
|
||||||
|
Language-Specific Examples
|
||||||
|
JavaScript
|
||||||
|
|
||||||
|
// Good: Explains WHY we debounce
|
||||||
|
// Debounce search to reduce API calls (500ms wait after last keystroke)
|
||||||
|
const debouncedSearch = debounce(searchAPI, 500);
|
||||||
|
|
||||||
|
// Bad: Obvious
|
||||||
|
let count = 0; // Initialize count to zero
|
||||||
|
count++; // Increment count
|
||||||
|
|
||||||
|
// Good: Explains algorithm choice
|
||||||
|
// Using Set for O(1) lookup instead of Array.includes() which is O(n)
|
||||||
|
const seen = new Set(ids);
|
||||||
|
|
||||||
|
Python
|
||||||
|
|
||||||
|
# Good: Explains the algorithm choice
|
||||||
|
# Using binary search because data is sorted and we need O(log n) performance
|
||||||
|
index = bisect.bisect_left(sorted_list, target)
|
||||||
|
|
||||||
|
# Bad: Redundant
|
||||||
|
def get_total(items):
|
||||||
|
return sum(items) # Return the sum of items
|
||||||
|
|
||||||
|
# Good: Explains why we're doing this
|
||||||
|
# Extract to separate function for type checking in mypy
|
||||||
|
def validate_user(user):
|
||||||
|
if not user or not user.id:
|
||||||
|
raise ValueError("Invalid user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
TypeScript
|
||||||
|
|
||||||
|
// Good: Explains the type assertion
|
||||||
|
// TypeScript can't infer this is never null after the check
|
||||||
|
const element = document.getElementById('app') as HTMLElement;
|
||||||
|
|
||||||
|
// Bad: Obvious
|
||||||
|
const sum = a + b; // Add a and b
|
||||||
|
|
||||||
|
// Good: Explains non-obvious behavior
|
||||||
|
// spread operator creates shallow copy; use JSON for deep copy
|
||||||
|
const newConfig = { ...config };
|
||||||
|
|
||||||
|
Comment Quality Checklist
|
||||||
|
|
||||||
|
Before committing, ensure your comments:
|
||||||
|
|
||||||
|
Explain WHY, not WHAT
|
||||||
|
Are grammatically correct and clear
|
||||||
|
Will remain accurate as code evolves
|
||||||
|
Add genuine value to code understanding
|
||||||
|
Are placed appropriately (above the code they describe)
|
||||||
|
Use proper spelling and professional language
|
||||||
|
Follow team conventions for annotation keywords
|
||||||
|
Could not be replaced by better naming or structure
|
||||||
|
Are not obvious statements about language features
|
||||||
|
Reference tickets/issues when applicable
|
||||||
|
|
||||||
|
Summary
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
Clear code - Self-explanatory through naming and structure
|
||||||
|
Good comments - Explain WHY when necessary
|
||||||
|
Documentation - API docs, docstrings for public interfaces
|
||||||
|
No comments - Better than bad comments that lie or clutter
|
||||||
|
|
||||||
|
Remember: Comments are a failure to make the code self-explanatory. Use them sparingly and wisely.
|
||||||
|
Key Takeaways
|
||||||
|
Goal Approach
|
||||||
|
Reduce comments Improve naming, extract functions, simplify logic
|
||||||
|
Improve clarity Use self-explanatory code structure, clear variable names
|
||||||
|
Document APIs Use docstrings/JSDoc for public interfaces
|
||||||
|
Explain WHY Comment only business logic, algorithms, workarounds
|
||||||
|
Maintain accuracy Update comments when code changes, or remove them
|
||||||
353
.claude/skills/typescript/SKILL.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
---
|
||||||
|
name: typescript
|
||||||
|
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
|
||||||
|
---
|
||||||
|
|
||||||
|
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
|
||||||
|
Naming Conventions
|
||||||
|
Type Convention Example
|
||||||
|
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
|
||||||
|
Variables, Parameters, Functions lowerCamelCase userName, processData
|
||||||
|
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
|
||||||
|
Type Parameters Single letter or UpperCamelCase T, ResponseType
|
||||||
|
Naming Principles
|
||||||
|
|
||||||
|
Descriptive names, avoid ambiguous abbreviations
|
||||||
|
Treat acronyms as words: loadHttpUrl not loadHTTPURL
|
||||||
|
No prefixes like opt_ for optional parameters
|
||||||
|
No trailing underscores for private properties
|
||||||
|
Single-letter variables only when scope is <10 lines
|
||||||
|
|
||||||
|
Variable Declarations
|
||||||
|
|
||||||
|
// Always use const by default
|
||||||
|
const users = getUsers();
|
||||||
|
|
||||||
|
// Use let only when reassignment is needed
|
||||||
|
let count = 0;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
// Never use var
|
||||||
|
// var x = 1; // WRONG
|
||||||
|
|
||||||
|
// One variable per declaration
|
||||||
|
const a = 1;
|
||||||
|
const b = 2;
|
||||||
|
// const a = 1, b = 2; // WRONG
|
||||||
|
|
||||||
|
Types and Interfaces
|
||||||
|
Prefer Interfaces Over Type Aliases
|
||||||
|
|
||||||
|
// Good: interface for object shapes
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid: type alias for object shapes
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type aliases OK for unions, intersections, mapped types
|
||||||
|
type Status = 'active' | 'inactive';
|
||||||
|
type Combined = TypeA & TypeB;
|
||||||
|
|
||||||
|
Type Inference
|
||||||
|
|
||||||
|
Leverage inference for trivially inferred types:
|
||||||
|
|
||||||
|
// Good: inference is clear
|
||||||
|
const name = 'Alice';
|
||||||
|
const items = [1, 2, 3];
|
||||||
|
|
||||||
|
// Good: explicit for complex expressions
|
||||||
|
const result: ProcessedData = complexTransformation(input);
|
||||||
|
|
||||||
|
Array Types
|
||||||
|
|
||||||
|
// Simple types: use T[]
|
||||||
|
const numbers: number[];
|
||||||
|
const names: readonly string[];
|
||||||
|
|
||||||
|
// Multi-dimensional: use T[][]
|
||||||
|
const matrix: number[][];
|
||||||
|
|
||||||
|
// Complex types: use Array<T>
|
||||||
|
const handlers: Array<(event: Event) => void>;
|
||||||
|
|
||||||
|
Null and Undefined
|
||||||
|
|
||||||
|
// Prefer optional fields over union with undefined
|
||||||
|
interface Config {
|
||||||
|
timeout?: number; // Good
|
||||||
|
// timeout: number | undefined; // Avoid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type aliases must NOT include |null or |undefined
|
||||||
|
type UserId = string; // Good
|
||||||
|
// type UserId = string | null; // WRONG
|
||||||
|
|
||||||
|
// May use == for null comparison (catches both null and undefined)
|
||||||
|
if (value == null) {
|
||||||
|
// handles both null and undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
Types to Avoid
|
||||||
|
|
||||||
|
// Avoid any - use unknown instead
|
||||||
|
function parse(input: unknown): Data { }
|
||||||
|
|
||||||
|
// Avoid {} - use unknown, Record<string, T>, or object
|
||||||
|
function process(obj: Record<string, unknown>): void { }
|
||||||
|
|
||||||
|
// Use lowercase primitives
|
||||||
|
let name: string; // Good
|
||||||
|
// let name: String; // WRONG
|
||||||
|
|
||||||
|
// Never use wrapper objects
|
||||||
|
// new String('hello') // WRONG
|
||||||
|
|
||||||
|
Classes
|
||||||
|
Structure
|
||||||
|
|
||||||
|
class UserService {
|
||||||
|
// Fields first, initialized where declared
|
||||||
|
private readonly cache = new Map<string, User>();
|
||||||
|
private lastAccess: Date | null = null;
|
||||||
|
|
||||||
|
// Constructor with parameter properties
|
||||||
|
constructor(
|
||||||
|
private readonly api: ApiClient,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Methods separated by blank lines
|
||||||
|
async getUser(id: string): Promise<User> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateId(id: string): boolean {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Visibility
|
||||||
|
|
||||||
|
class Example {
|
||||||
|
// private by default, only use public when needed externally
|
||||||
|
private internalState = 0;
|
||||||
|
|
||||||
|
// readonly for properties never reassigned after construction
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
// Never use #private syntax - use TypeScript visibility
|
||||||
|
// #field = 1; // WRONG
|
||||||
|
private field = 1; // Good
|
||||||
|
}
|
||||||
|
|
||||||
|
Avoid Arrow Functions as Properties
|
||||||
|
|
||||||
|
class Handler {
|
||||||
|
// Avoid: arrow function as property
|
||||||
|
// handleClick = () => { ... };
|
||||||
|
|
||||||
|
// Good: instance method
|
||||||
|
handleClick(): void {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind at call site if needed
|
||||||
|
element.addEventListener('click', () => handler.handleClick());
|
||||||
|
|
||||||
|
Static Methods
|
||||||
|
|
||||||
|
Never use this in static methods
|
||||||
|
Call on defining class, not subclasses
|
||||||
|
|
||||||
|
Functions
|
||||||
|
Prefer Function Declarations
|
||||||
|
|
||||||
|
// Good: function declaration for named functions
|
||||||
|
function processData(input: Data): Result {
|
||||||
|
return transform(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow functions when type annotation needed
|
||||||
|
const handler: EventHandler = (event) => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
Arrow Function Bodies
|
||||||
|
|
||||||
|
// Concise body only when return value is used
|
||||||
|
const double = (x: number) => x * 2;
|
||||||
|
|
||||||
|
// Block body when return should be void
|
||||||
|
const log = (msg: string) => {
|
||||||
|
console.log(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
|
||||||
|
// Use rest parameters, not arguments
|
||||||
|
function sum(...numbers: number[]): number {
|
||||||
|
return numbers.reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructuring for multiple optional params
|
||||||
|
interface Options {
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
}
|
||||||
|
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never name a parameter 'arguments'
|
||||||
|
|
||||||
|
Imports and Exports
|
||||||
|
Always Use Named Exports
|
||||||
|
|
||||||
|
// Good: named exports
|
||||||
|
export function processData() { }
|
||||||
|
export class UserService { }
|
||||||
|
export interface Config { }
|
||||||
|
|
||||||
|
// Never use default exports
|
||||||
|
// export default class UserService { } // WRONG
|
||||||
|
|
||||||
|
Import Styles
|
||||||
|
|
||||||
|
// Module import for large APIs
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
// Named imports for frequently used symbols
|
||||||
|
import { readFile, writeFile } from 'fs/promises';
|
||||||
|
|
||||||
|
// Type-only imports when only used as types
|
||||||
|
import type { User, Config } from './types';
|
||||||
|
|
||||||
|
Module Organization
|
||||||
|
|
||||||
|
Use modules, never namespace Foo { }
|
||||||
|
Never use require() - use ES6 imports
|
||||||
|
Use relative imports within same project
|
||||||
|
Avoid excessive ../../../
|
||||||
|
|
||||||
|
Control Structures
|
||||||
|
Always Use Braces
|
||||||
|
|
||||||
|
// Good
|
||||||
|
if (condition) {
|
||||||
|
doSomething();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exception: single-line if
|
||||||
|
if (condition) return early;
|
||||||
|
|
||||||
|
Loops
|
||||||
|
|
||||||
|
// Prefer for...of for arrays
|
||||||
|
for (const item of items) {
|
||||||
|
process(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Object methods with for...of for objects
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never use unfiltered for...in on arrays
|
||||||
|
|
||||||
|
Equality
|
||||||
|
|
||||||
|
// Always use === and !==
|
||||||
|
if (a === b) { }
|
||||||
|
|
||||||
|
// Exception: == null catches both null and undefined
|
||||||
|
if (value == null) { }
|
||||||
|
|
||||||
|
Switch Statements
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case Status.Active:
|
||||||
|
handleActive();
|
||||||
|
break;
|
||||||
|
case Status.Inactive:
|
||||||
|
handleInactive();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Always include default, even if empty
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Exception Handling
|
||||||
|
|
||||||
|
// Always throw Error instances
|
||||||
|
throw new Error('Something went wrong');
|
||||||
|
// throw 'error'; // WRONG
|
||||||
|
|
||||||
|
// Catch with unknown type
|
||||||
|
try {
|
||||||
|
riskyOperation();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
logger.error(e.message);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty catch needs justification comment
|
||||||
|
try {
|
||||||
|
optional();
|
||||||
|
} catch {
|
||||||
|
// Intentionally ignored: fallback behavior handles this
|
||||||
|
}
|
||||||
|
|
||||||
|
Type Assertions
|
||||||
|
|
||||||
|
// Use 'as' syntax, not angle brackets
|
||||||
|
const input = value as string;
|
||||||
|
// const input = <string>value; // WRONG in TSX, avoid everywhere
|
||||||
|
|
||||||
|
// Double assertion through unknown when needed
|
||||||
|
const config = (rawData as unknown) as Config;
|
||||||
|
|
||||||
|
// Add comment explaining why assertion is safe
|
||||||
|
const element = document.getElementById('app') as HTMLElement;
|
||||||
|
// Safe: element exists in index.html
|
||||||
|
|
||||||
|
Strings
|
||||||
|
|
||||||
|
// Use single quotes for string literals
|
||||||
|
const name = 'Alice';
|
||||||
|
|
||||||
|
// Template literals for interpolation or multiline
|
||||||
|
const message = `Hello, ${name}!`;
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Never use backslash line continuations
|
||||||
|
|
||||||
|
Disallowed Features
|
||||||
|
Feature Alternative
|
||||||
|
var const or let
|
||||||
|
Array() constructor [] literal
|
||||||
|
Object() constructor {} literal
|
||||||
|
any type unknown
|
||||||
|
namespace modules
|
||||||
|
require() import
|
||||||
|
Default exports Named exports
|
||||||
|
#private fields private modifier
|
||||||
|
eval() Never use
|
||||||
|
const enum Regular enum
|
||||||
|
debugger Remove before commit
|
||||||
|
with Never use
|
||||||
|
Prototype modification Never modify
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
jsx: true // Allows for the parsing of JSX
|
jsx: true // Allows for the parsing of JSX
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: ["@typescript-eslint", "css-modules"],
|
plugins: ["@typescript-eslint", "css-modules"],
|
||||||
settings: {
|
settings: {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
@@ -18,9 +18,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fine tune rules
|
// Fine tune rules
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-var-requires": 0
|
"@typescript-eslint/no-var-requires": 0
|
||||||
|
|||||||
2
.github/workflows/docker-image.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
- name: Publish to Registry
|
- name: Publish to Registry
|
||||||
uses: elgohr/Publish-Docker-Github-Action@master
|
uses: elgohr/Publish-Docker-Github-Action@v5
|
||||||
with:
|
with:
|
||||||
name: frishi/threetwo
|
name: frishi/threetwo
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -4,6 +4,7 @@ comics/
|
|||||||
docs/
|
docs/
|
||||||
userdata/
|
userdata/
|
||||||
dist/
|
dist/
|
||||||
|
storybook-static/*
|
||||||
src/client/assets/scss/App.css
|
src/client/assets/scss/App.css
|
||||||
/server/
|
/server/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
stories: [
|
|
||||||
"../src/client/stories/*.stories.mdx",
|
|
||||||
"../src/client/stories/*.stories.@(js|jsx|ts|tsx)",
|
|
||||||
],
|
|
||||||
staticDirs: [
|
|
||||||
"../src/client/stories/assets"
|
|
||||||
],
|
|
||||||
addons: [
|
|
||||||
"@storybook/addon-links",
|
|
||||||
"@storybook/preset-scss",
|
|
||||||
"@storybook/addon-essentials",
|
|
||||||
"@storybook/addon-interactions",
|
|
||||||
],
|
|
||||||
framework: "@storybook/react",
|
|
||||||
core: {
|
|
||||||
builder: "webpack5",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
19
.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
|
addons: [
|
||||||
|
"@storybook/addon-links",
|
||||||
|
"@storybook/addon-essentials",
|
||||||
|
"@storybook/addon-onboarding",
|
||||||
|
"@storybook/addon-interactions",
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: "@storybook/react-vite",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: "tag",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
3
.storybook/preview-head.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export const parameters = {
|
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
18
.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Preview } from "@storybook/react";
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
backgrounds: {
|
||||||
|
default: "light",
|
||||||
|
},
|
||||||
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1013 KiB |
BIN
Dashboard.png
|
Before Width: | Height: | Size: 3.5 MiB |
37
Dockerfile
@@ -1,19 +1,36 @@
|
|||||||
FROM node:17.3-alpine
|
# Use Node.js 22 as the base image
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
WORKDIR /threetwo
|
WORKDIR /threetwo
|
||||||
|
|
||||||
COPY package.json ./
|
# Copy package.json and yarn.lock to leverage Docker cache
|
||||||
COPY yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
COPY nodemon.json ./
|
|
||||||
COPY jsdoc.json ./
|
|
||||||
|
|
||||||
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
# Install build dependencies necessary for native modules (for node-sass)
|
||||||
RUN apk --no-cache add g++ make libpng-dev python3 git libc6-compat autoconf automake bash libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
RUN apk --no-cache add \
|
||||||
RUN yarn --ignore-engines
|
g++ \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
nasm \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Install node modules
|
||||||
|
RUN yarn install --ignore-engines
|
||||||
|
|
||||||
|
# Explicitly install sass
|
||||||
|
RUN yarn add -D sass
|
||||||
|
|
||||||
|
# Copy the rest of the application files into the container
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3050
|
|
||||||
|
|
||||||
ENTRYPOINT [ "npm", "start" ]
|
# Expose the application port (default for Vite)
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Start the application with yarn
|
||||||
|
ENTRYPOINT ["yarn", "start"]
|
||||||
|
|||||||
BIN
Library.png
|
Before Width: | Height: | Size: 1.4 MiB |
35
README.md
@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
|
|||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||

|
#### Dashboard
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
#### Issue View
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### DC++ Search
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Import
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Comic Vine Matching, Metadata Scraping
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 🦄 Early Development Support Channel
|
### 🦄 Early Development Support Channel
|
||||||
|
|
||||||
@@ -28,7 +39,8 @@ ThreeTwo! currently is set up as:
|
|||||||
1. The UI, this repo.
|
1. The UI, this repo.
|
||||||
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
|
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
|
||||||
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
|
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
|
||||||
4. [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-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.
|
||||||
|
|
||||||
## Docker Instructions
|
## Docker Instructions
|
||||||
|
|
||||||
@@ -40,24 +52,21 @@ For debugging and troubleshooting, you can run this app locally using these step
|
|||||||
|
|
||||||
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
|
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
|
||||||
2. `yarn run dev` (you can ignore the warnings)
|
2. `yarn run dev` (you can ignore the warnings)
|
||||||
3. This will open `http://localhost:3050` in your default browser
|
3. This will open `http://localhost:5173` in your default browser
|
||||||
4. For testing `OPDS` functionality, create a folder called `comics` under `/src/server` and put some comics in there. The `OPDS` feed is accessed to `http://localhost:8050/api/opds`
|
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.
|
||||||
5. 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
|
### Docker
|
||||||
|
|
||||||
1. `docker-compose up` is taking a long time
|
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.
|
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?
|
2. What folder do my comics go in?
|
||||||
|
|
||||||
Your comics go in the `comics` directory at the root of this project.
|
Your comics go in the `comics` directory at the root of this project.
|
||||||
|
|
||||||
|
|
||||||
## Contribution Guidelines
|
## Contribution Guidelines
|
||||||
|
|
||||||
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)
|
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)
|
||||||
|
|
||||||
|
|||||||
1
__mocks__/fileMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = 'test-file-stub';
|
||||||
16
codegen.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
schema: http://localhost:3000/graphql
|
||||||
|
documents: 'src/client/graphql/**/*.graphql'
|
||||||
|
generates:
|
||||||
|
src/client/graphql/generated.ts:
|
||||||
|
plugins:
|
||||||
|
- typescript
|
||||||
|
- typescript-operations
|
||||||
|
- typescript-react-query
|
||||||
|
config:
|
||||||
|
fetcher:
|
||||||
|
func: './fetcher#fetcher'
|
||||||
|
isReactHook: false
|
||||||
|
exposeFetcher: true
|
||||||
|
exposeQueryKeys: true
|
||||||
|
addInfiniteQuery: true
|
||||||
|
reactQueryVersion: 5
|
||||||
1
funding.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [rishighan]
|
||||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>Three Two!</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="dark:bg-slate-600">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/client/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
jest.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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',
|
||||||
|
],
|
||||||
|
};
|
||||||
25
jest.setup.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
require('@testing-library/jest-dom');
|
||||||
|
|
||||||
|
// Mock window.matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation(query => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
|
global.localStorage = localStorageMock;
|
||||||
51
jsdoc.json
@@ -1,34 +1,25 @@
|
|||||||
{
|
{
|
||||||
"tags": {
|
"tags": {
|
||||||
"allowUnknownTags": true,
|
"allowUnknownTags": false
|
||||||
"dictionaries": [
|
},
|
||||||
"jsdoc",
|
"source": {
|
||||||
"closure"
|
"include": [
|
||||||
]
|
"./src/client"
|
||||||
},
|
],
|
||||||
"source": {
|
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||||
"include": [
|
},
|
||||||
"./src/client"
|
"plugins": [
|
||||||
|
"plugins/markdown"
|
||||||
],
|
],
|
||||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
"opts": {
|
||||||
},
|
"template": "node_modules/tui-jsdoc-template",
|
||||||
"plugins": [
|
"encoding": "utf8",
|
||||||
"better-docs/component",
|
"destination": "docs/",
|
||||||
"better-docs/category",
|
"recurse": true,
|
||||||
"plugins/markdown",
|
"verbose": true
|
||||||
"node_modules/better-docs/typescript"
|
},
|
||||||
],
|
"templates": {
|
||||||
"templates": {
|
"cleverLinks": false,
|
||||||
"better-docs": {
|
"monospaceLinks": false
|
||||||
"name": "ThreeTwo UI components"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"opts": {
|
|
||||||
"destination": "docs/",
|
|
||||||
"readme": "README.md",
|
|
||||||
"recurse": true,
|
|
||||||
"encoding": "utf8",
|
|
||||||
"verbose": true,
|
|
||||||
"template": "node_modules/better-docs"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
13
nodemon.json
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"ignore": [
|
|
||||||
"**/*.test.ts",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"node_modules",
|
|
||||||
"src/client"
|
|
||||||
],
|
|
||||||
"watch": [
|
|
||||||
"src/server"
|
|
||||||
],
|
|
||||||
"exec": "tsc -p tsconfig.server.json && node server/",
|
|
||||||
"ext": "ts"
|
|
||||||
}
|
|
||||||
19434
package-lock.json
generated
Normal file
296
package.json
@@ -1,180 +1,150 @@
|
|||||||
{
|
{
|
||||||
"name": "threetwo",
|
"name": "threetwo",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"description": "ThreeTwo! A comic book curator.",
|
"description": "ThreeTwo! A good comic book curator.",
|
||||||
"main": "server/index.js",
|
|
||||||
"typings": "server/index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "vite build",
|
||||||
"start": "npm run build && npm run server",
|
"dev": "rimraf dist && yarn build && vite",
|
||||||
"client": "webpack serve --mode development --devtool inline-source-map --hot",
|
"start": "yarn build && vite",
|
||||||
"server": "tsc -p tsconfig.server.json && node server/",
|
|
||||||
"dev": "concurrently \"nodemon\" \"npm run client\"",
|
|
||||||
"server-dev": "nodemon",
|
|
||||||
"docs": "jsdoc -c jsdoc.json",
|
"docs": "jsdoc -c jsdoc.json",
|
||||||
"storybook": "start-storybook -p 6006",
|
"test": "jest",
|
||||||
"build-storybook": "build-storybook"
|
"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"
|
||||||
},
|
},
|
||||||
"author": "Rishi Ghan",
|
"author": "Rishi Ghan",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.13.17",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/core": "^4.0.0",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@dnd-kit/sortable": "^5.0.0",
|
"@floating-ui/react": "^0.27.18",
|
||||||
"@dnd-kit/utilities": "^3.2.0",
|
"@floating-ui/react-dom": "^2.1.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@parcel/config-default": "^2.6.0",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@redux-devtools/extension": "^3.2.2",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.5.11",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/mime-types": "^2.1.0",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-dom": "^17.0.2",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@types/react-redux": "^7.1.16",
|
"airdcpp-apisocket": "^3.0.0-beta.14",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"axios": "^1.13.5",
|
||||||
"@types/sharp": "^0.28.0",
|
"axios-cache-interceptor": "^1.11.4",
|
||||||
"@types/socket.io": "^3.0.2",
|
"axios-rate-limit": "^1.6.2",
|
||||||
"@types/socket.io-client": "^3.0.0",
|
"babel-plugin-styled-components": "^2.1.4",
|
||||||
"airdcpp-apisocket": "^2.4.4",
|
"date-fns": "^4.1.0",
|
||||||
"array-sort-by": "^1.2.1",
|
"dayjs": "^1.11.19",
|
||||||
"axios": "^0.27.2",
|
"ellipsize": "^0.7.0",
|
||||||
"axios-rate-limit": "^1.3.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"axios-simple-cache-adapter": "^1.1.0",
|
"filename-parser": "^1.0.4",
|
||||||
"babel-polyfill": "^6.26.0",
|
"final-form": "^5.0.0",
|
||||||
"babel-preset-minify": "^0.5.2",
|
"final-form-arrays": "^4.0.0",
|
||||||
"better-docs": "^2.7.2",
|
"focus-trap-react": "^12.0.0",
|
||||||
"comlink-loader": "^2.0.0",
|
"graphql": "^16.13.1",
|
||||||
"date-fns": "^2.28.0",
|
"history": "^5.3.0",
|
||||||
"dayjs": "^1.10.6",
|
"html-to-text": "^9.0.5",
|
||||||
"ellipsize": "^0.1.0",
|
"i18next": "^25.8.13",
|
||||||
"express": "^4.17.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"fastest-validator": "^1.11.0",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"filename-parser": "^1.0.2",
|
"immer": "^11.1.4",
|
||||||
"final-form": "^4.20.2",
|
"jsdoc": "^4.0.5",
|
||||||
"final-form-arrays": "^3.0.2",
|
"lodash": "^4.17.23",
|
||||||
"html-to-text": "^8.1.0",
|
"motion": "^12.38.0",
|
||||||
"jsdoc": "^3.6.10",
|
"pretty-bytes": "^7.1.0",
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"opds-extra": "^3.0.9",
|
|
||||||
"pretty-bytes": "^5.6.0",
|
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"qs": "^6.10.5",
|
"qs": "^6.15.0",
|
||||||
"react": "^18.2.0",
|
"react": "^19.2.4",
|
||||||
"react-collapsible": "^2.9.0",
|
"react-collapsible": "^2.10.0",
|
||||||
"react-comic-viewer": "^0.4.0",
|
"react-comic-viewer": "^0.5.1",
|
||||||
"react-day-picker": "^8.0.6",
|
"react-day-picker": "^9.13.2",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-fast-compare": "^3.2.0",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-final-form": "^6.5.9",
|
"react-final-form": "^7.0.0",
|
||||||
"react-final-form-arrays": "^3.1.3",
|
"react-final-form-arrays": "^4.0.0",
|
||||||
"react-loader-spinner": "^4.0.0",
|
"react-i18next": "^16.5.4",
|
||||||
"react-masonry-css": "^1.0.16",
|
"react-loader-spinner": "^8.0.2",
|
||||||
"react-modal": "^3.15.1",
|
"react-modal": "^3.16.3",
|
||||||
"react-redux": "^7.2.6",
|
"react-router": "^7.13.1",
|
||||||
"react-router": "^6.2.2",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-router-dom": "^6.2.2",
|
"react-select": "^5.10.2",
|
||||||
"react-select": "^5.3.2",
|
"react-select-async-paginate": "^0.7.11",
|
||||||
"react-select-async-paginate": "^0.6.1",
|
"react-sliding-pane": "^7.3.0",
|
||||||
"react-slick": "^0.29.0",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"react-sliding-pane": "^7.1.0",
|
"react-toastify": "^11.0.5",
|
||||||
"react-stickynode": "^4.0.0",
|
"rxjs": "^7.8.2",
|
||||||
"react-textarea-autosize": "^8.3.4",
|
"socket.io-client": "^4.8.3",
|
||||||
"reapop": "^4.0.5",
|
"styled-components": "^6.3.11",
|
||||||
"redux-first-history": "^5.0.9",
|
|
||||||
"redux-socket.io-middleware": "^1.0.4",
|
|
||||||
"redux-thunk": "^2.4.1",
|
|
||||||
"sharp": "^0.30.5",
|
|
||||||
"slick-carousel": "^1.8.1",
|
|
||||||
"socket.io-client": "^4.3.2",
|
|
||||||
"styled-components": "^5.3.5",
|
|
||||||
"threetwo-ui-typings": "^1.0.14",
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
"voca": "^1.4.0",
|
"vaul": "^1.1.2",
|
||||||
"websocket": "^1.0.34",
|
"vite": "^7.3.1",
|
||||||
"ws": "^7.5.3",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"xml2js": "^0.4.23",
|
"websocket": "^1.0.35",
|
||||||
"xregexp": "^5.0.2"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.13.10",
|
"@graphql-codegen/cli": "^6.1.2",
|
||||||
"@babel/core": "^7.13.10",
|
"@graphql-codegen/typescript": "^5.0.8",
|
||||||
"@babel/plugin-syntax-top-level-await": "^7.14.5",
|
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
"@graphql-codegen/typescript-react-query": "^6.1.2",
|
||||||
"@babel/preset-env": "^7.13.10",
|
"@iconify-json/solar": "^1.2.5",
|
||||||
"@babel/preset-react": "^7.12.13",
|
"@iconify/json": "^2.2.443",
|
||||||
"@babel/preset-typescript": "^7.13.0",
|
"@iconify/tailwind": "^1.2.0",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
|
"@iconify/tailwind4": "^1.2.1",
|
||||||
"@storybook/addon-actions": "^6.5.9",
|
"@iconify/utils": "^3.1.0",
|
||||||
"@storybook/addon-essentials": "^6.5.9",
|
"@storybook/addon-essentials": "^8.6.17",
|
||||||
"@storybook/addon-interactions": "^6.5.9",
|
"@storybook/addon-interactions": "^8.6.17",
|
||||||
"@storybook/addon-links": "^6.5.9",
|
"@storybook/addon-links": "^8.6.17",
|
||||||
"@storybook/builder-webpack4": "^6.5.9",
|
"@storybook/addon-onboarding": "^8.6.17",
|
||||||
"@storybook/builder-webpack5": "^6.5.9",
|
"@storybook/blocks": "^8.6.17",
|
||||||
"@storybook/manager-webpack4": "^6.5.9",
|
"@storybook/react": "^8.6.17",
|
||||||
"@storybook/manager-webpack5": "^6.5.9",
|
"@storybook/react-vite": "^8.6.17",
|
||||||
"@storybook/preset-scss": "^1.0.3",
|
"@storybook/testing-library": "^0.2.2",
|
||||||
"@storybook/react": "^6.5.9",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@storybook/testing-library": "^0.0.13",
|
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tsconfig/node14": "^1.0.0",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/express": "^4.17.8",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/jest": "^26.0.20",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/lodash": "^4.14.168",
|
"@types/ellipsize": "^0.1.3",
|
||||||
"@types/node": "^14.14.34",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/react": "^17.0.3",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/react-dom": "^17.0.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@types/react": "^19.2.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/parser": "^4.17.0",
|
"@types/react-redux": "^7.1.34",
|
||||||
"awesome-typescript-loader": "^5.2.1",
|
"autoprefixer": "^10.4.27",
|
||||||
"babel-eslint": "^10.0.0",
|
"docdash": "^2.0.2",
|
||||||
"babel-loader": "^8.2.2",
|
"eslint": "^10.0.2",
|
||||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"body-parser": "^1.19.0",
|
"eslint-plugin-css-modules": "^2.12.0",
|
||||||
"buffer": "^6.0.3",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"bulma": "^0.9.3",
|
"eslint-plugin-jsdoc": "^62.7.1",
|
||||||
"clean-webpack-plugin": "^1.0.0",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"comlink": "^4.3.0",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"concurrently": "^4.0.0",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"copy-webpack-plugin": "^9.0.1",
|
"eslint-plugin-storybook": "^0.11.1",
|
||||||
"css-loader": "^5.1.2",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"eslint": "^7.22.0",
|
|
||||||
"eslint-config-airbnb": "^18.2.1",
|
|
||||||
"eslint-config-airbnb-base": "^14.2.1",
|
|
||||||
"eslint-config-prettier": "^8.1.0",
|
|
||||||
"eslint-plugin-css-modules": "^2.11.0",
|
|
||||||
"eslint-plugin-import": "^2.22.1",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
|
||||||
"eslint-plugin-react": "^7.22.0",
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"fs-extra": "^9.1.0",
|
|
||||||
"html-webpack-plugin": "^5.3.1",
|
|
||||||
"http-response-stream": "^1.0.9",
|
|
||||||
"image-webpack-loader": "^8.1.0",
|
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^30.2.0",
|
||||||
"mini-css-extract-plugin": "^1.4.1",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"node-sass": "^7.0.1",
|
"postcss": "^8.5.6",
|
||||||
"nodemon": "^1.17.3",
|
"postcss-import": "^16.1.1",
|
||||||
"npm": "^8.11.0",
|
"prettier": "^3.8.1",
|
||||||
"prettier": "^2.2.1",
|
"react-refresh": "^0.18.0",
|
||||||
"react-refresh": "^0.14.0",
|
"rimraf": "^6.1.3",
|
||||||
"rimraf": "^3.0.2",
|
"sass": "^1.97.3",
|
||||||
"sass-loader": "^11.0.1",
|
"storybook": "^8.6.17",
|
||||||
"source-map-loader": "^0.2.4",
|
"tailwindcss": "^4.2.1",
|
||||||
"string-similarity": "^4.0.4",
|
"ts-jest": "^29.4.6",
|
||||||
"style-loader": "^2.0.0",
|
"tui-jsdoc-template": "^1.2.2",
|
||||||
"tslint": "^6.1.3",
|
"typescript": "^6.0.2",
|
||||||
"typescript": "^4.2.3",
|
"wait-on": "^9.0.4"
|
||||||
"url-loader": "^1.0.1",
|
|
||||||
"webpack": "^5.73.0",
|
|
||||||
"webpack-bundle-analyzer": "^4.4.2",
|
|
||||||
"webpack-cli": "^4.6.0",
|
|
||||||
"webpack-dev-server": "^4.7.4",
|
|
||||||
"webpack-merge": "^5.7.3"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@storybook/react/webpack": "^5"
|
"jackspeak": "2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-import": {},
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/fonts/Hasklig-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Heavy.otf
Normal file
BIN
public/fonts/PPObjectSans-HeavySlanted.otf
Normal file
BIN
public/fonts/PPObjectSans-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Slanted.otf
Normal file
@@ -1,15 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
||||||
<title>Three Two!</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
BIN
screenshots/CVMatching.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
screenshots/ComicDetail.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
screenshots/DC++Searching.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
screenshots/Dashboard.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/Import.jpg
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
screenshots/Library.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
@@ -7,11 +7,12 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
|
|||||||
|
|
||||||
It is based on React 18, and uses:
|
It is based on React 18, and uses:
|
||||||
|
|
||||||
1. _Redux_ for state management
|
1. _zustand_ for state management
|
||||||
2. _socket.io_ for transferring data in real-time
|
2. _socket.io_ for transferring data in real-time
|
||||||
3. _React Router_ for routing
|
3. _React Router_ for routing
|
||||||
4. React DnD for drag-and-drop
|
4. React DnD for drag-and-drop
|
||||||
5. @tanstack/react-table for all tables
|
5. @tanstack/react-table for all tables
|
||||||
|
6. @tanstack/react-query for API calls
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ interface SearchData {
|
|||||||
priority: PriorityEnum;
|
priority: PriorityEnum;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<NodeJS.Timeout> {
|
export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const toggleAirDCPPSocketConnectionStatus =
|
export const toggleAirDCPPSocketConnectionStatus =
|
||||||
(status: String, payload?: any) => async (dispatch) => {
|
(status: String, payload?: any) => async (dispatch) => {
|
||||||
@@ -55,87 +55,9 @@ export const toggleAirDCPPSocketConnectionStatus =
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log("Can't set AirDC++ socket status.");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const search =
|
|
||||||
(data: SearchData, ADCPPSocket: any, credentials: any) =>
|
|
||||||
async (dispatch) => {
|
|
||||||
try {
|
|
||||||
if (!ADCPPSocket.isConnected()) {
|
|
||||||
await ADCPPSocket();
|
|
||||||
}
|
|
||||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
|
||||||
dispatch({
|
|
||||||
type: AIRDCPP_SEARCH_IN_PROGRESS,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: AIRDCPP_SEARCH_RESULTS_ADDED,
|
|
||||||
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
|
|
||||||
dispatch({
|
|
||||||
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
|
|
||||||
groupedResult,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
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}`,
|
|
||||||
);
|
|
||||||
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
|
|
||||||
dispatch({
|
|
||||||
type: AIRDCPP_HUB_SEARCHES_SENT,
|
|
||||||
searchInfo,
|
|
||||||
instance,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
instance.id,
|
|
||||||
);
|
|
||||||
// Finally, perform the actual search
|
|
||||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadAirDCPPItem =
|
export const downloadAirDCPPItem =
|
||||||
(
|
(
|
||||||
searchInstanceId: Number,
|
searchInstanceId: Number,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import rateLimiter from "axios-rate-limit";
|
import rateLimiter from "axios-rate-limit";
|
||||||
import {
|
import { setupCache } from "axios-cache-interceptor";
|
||||||
AxiosCacheRequestConfig,
|
|
||||||
createCacheAdapter,
|
|
||||||
} from "axios-simple-cache-adapter";
|
|
||||||
import qs from "qs";
|
|
||||||
import {
|
import {
|
||||||
CV_SEARCH_SUCCESS,
|
CV_SEARCH_SUCCESS,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -26,32 +22,28 @@ import {
|
|||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
} from "../constants/endpoints";
|
} from "../constants/endpoints";
|
||||||
|
|
||||||
const axiosCacheAdapter = createCacheAdapter();
|
|
||||||
const http = rateLimiter(axios.create(), {
|
const http = rateLimiter(axios.create(), {
|
||||||
maxRequests: 1,
|
maxRequests: 1,
|
||||||
perMilliseconds: 1000,
|
perMilliseconds: 1000,
|
||||||
maxRPS: 1,
|
maxRPS: 1,
|
||||||
});
|
});
|
||||||
|
const cachedAxios = setupCache(axios);
|
||||||
export const getWeeklyPullList = (options) => async (dispatch) => {
|
export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
|
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||||
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
|
||||||
method: "get",
|
method: "get",
|
||||||
params: options,
|
params: options,
|
||||||
axiosCacheAdapter,
|
}).then((response) => {
|
||||||
cache: 1000, // value in MS
|
|
||||||
} as AxiosCacheRequestConfig).then((response) => {
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_WEEKLY_PULLLIST_FETCHED,
|
type: CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
data: response.data.result,
|
data: response.data.result,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
// Error handling could be added here if needed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,9 +62,6 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
paramsSerializer: (params) => {
|
|
||||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (options.callURIAction) {
|
switch (options.callURIAction) {
|
||||||
@@ -84,10 +73,9 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log("Could not complete request.");
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_GENERIC_FAILURE,
|
type: CV_API_GENERIC_FAILURE,
|
||||||
error,
|
error,
|
||||||
@@ -110,7 +98,6 @@ export const getIssuesForSeries =
|
|||||||
comicObjectID,
|
comicObjectID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(issues);
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
issues: issues.data.results,
|
issues: issues.data.results,
|
||||||
@@ -137,7 +124,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
|
|||||||
queryObjects,
|
queryObjects,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||||
matches: foo.data,
|
matches: foo.data,
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import {
|
|||||||
COMICVINE_SERVICE_URI,
|
COMICVINE_SERVICE_URI,
|
||||||
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
LIBRARY_SERVICE_HOST,
|
|
||||||
SEARCH_SERVICE_BASE_URI,
|
SEARCH_SERVICE_BASE_URI,
|
||||||
|
JOB_QUEUE_SERVICE_BASE_URI,
|
||||||
} from "../constants/endpoints";
|
} from "../constants/endpoints";
|
||||||
import {
|
import {
|
||||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
|
||||||
IMS_RECENT_COMICS_FETCHED,
|
IMS_RECENT_COMICS_FETCHED,
|
||||||
IMS_WANTED_COMICS_FETCHED,
|
IMS_WANTED_COMICS_FETCHED,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -22,23 +21,37 @@ import {
|
|||||||
LS_IMPORT,
|
LS_IMPORT,
|
||||||
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
|
||||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
SS_SEARCH_RESULTS_FETCHED,
|
SS_SEARCH_RESULTS_FETCHED,
|
||||||
SS_SEARCH_IN_PROGRESS,
|
SS_SEARCH_IN_PROGRESS,
|
||||||
FILEOPS_STATE_RESET,
|
FILEOPS_STATE_RESET,
|
||||||
LS_IMPORT_CALL_IN_PROGRESS,
|
LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
LS_TOGGLE_IMPORT_QUEUE,
|
|
||||||
SS_SEARCH_FAILED,
|
SS_SEARCH_FAILED,
|
||||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
WANTED_COMICS_FETCHED,
|
WANTED_COMICS_FETCHED,
|
||||||
VOLUMES_FETCHED,
|
VOLUMES_FETCHED,
|
||||||
CV_WEEKLY_PULLLIST_FETCHED,
|
LIBRARY_SERVICE_HEALTH,
|
||||||
|
LS_SET_QUEUE_STATUS,
|
||||||
|
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
import { success } from "react-notification-system-redux";
|
|
||||||
|
|
||||||
import { isNil, map } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
|
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
|
||||||
|
axios
|
||||||
|
.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
|
||||||
|
method: "GET",
|
||||||
|
transformResponse: (r: string) => JSON.parse(r),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
dispatch({
|
||||||
|
type: LIBRARY_SERVICE_HEALTH,
|
||||||
|
status: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||||
return axios
|
return axios
|
||||||
.request<Array<IFolderData>>({
|
.request<Array<IFolderData>>({
|
||||||
@@ -74,19 +87,38 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
|
|||||||
// autoDismiss: 0,
|
// autoDismiss: 0,
|
||||||
// }),
|
// }),
|
||||||
// );
|
// );
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LS_IMPORT,
|
type: LS_IMPORT,
|
||||||
meta: { remote: true },
|
});
|
||||||
data: {},
|
|
||||||
|
await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const toggleImportQueueStatus = (options) => async (dispatch) => {
|
|
||||||
|
export const getImportJobResultStatistics = () => async (dispatch) => {
|
||||||
|
const result = await axios.request({
|
||||||
|
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LS_TOGGLE_IMPORT_QUEUE,
|
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||||
meta: { remote: true },
|
data: result.data,
|
||||||
data: { manjhul: "jigyadam", action: options.action },
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setQueueControl =
|
||||||
|
(queueAction: string, queueStatus: string) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: LS_SET_QUEUE_STATUS,
|
||||||
|
meta: { remote: true },
|
||||||
|
data: { queueAction, queueStatus },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches comic book metadata for various types
|
* Fetches comic book metadata for various types
|
||||||
* @return metadata for the comic book object categories
|
* @return metadata for the comic book object categories
|
||||||
@@ -118,7 +150,7 @@ export const getComicBooks = (options) => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("Unrecognized comic status.");
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,49 +159,50 @@ export const getComicBooks = (options) => async (dispatch) => {
|
|||||||
* @returns Nothing.
|
* @returns Nothing.
|
||||||
* @param payload
|
* @param payload
|
||||||
*/
|
*/
|
||||||
export const importToDB = (sourceName: string, metadata?: any) => (dispatch) => {
|
export const importToDB =
|
||||||
try {
|
(sourceName: string, metadata?: any) => (dispatch) => {
|
||||||
const comicBookMetadata = {
|
try {
|
||||||
importType: "new",
|
const comicBookMetadata = {
|
||||||
payload: {
|
importType: "new",
|
||||||
rawFileDetails: {
|
payload: {
|
||||||
name: "",
|
rawFileDetails: {
|
||||||
},
|
name: "",
|
||||||
importStatus: {
|
|
||||||
isImported: true,
|
|
||||||
tagged: false,
|
|
||||||
matchedResult: {
|
|
||||||
score: "0",
|
|
||||||
},
|
},
|
||||||
|
importStatus: {
|
||||||
|
isImported: true,
|
||||||
|
tagged: false,
|
||||||
|
matchedResult: {
|
||||||
|
score: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourcedMetadata: metadata || null,
|
||||||
|
acquisition: { source: { wanted: true, name: sourceName } },
|
||||||
},
|
},
|
||||||
sourcedMetadata: metadata || null,
|
};
|
||||||
acquisition: { source: { wanted: true, name: sourceName } },
|
dispatch({
|
||||||
}
|
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
};
|
|
||||||
dispatch({
|
|
||||||
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
|
||||||
});
|
|
||||||
return axios
|
|
||||||
.request({
|
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
|
||||||
method: "POST",
|
|
||||||
data: comicBookMetadata,
|
|
||||||
// transformResponse: (r: string) => JSON.parse(r),
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
const { data } = response;
|
|
||||||
dispatch({
|
|
||||||
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
|
||||||
importResult: data,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
return axios
|
||||||
dispatch({
|
.request({
|
||||||
type: IMS_CV_METADATA_IMPORT_FAILED,
|
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||||
importError: error,
|
method: "POST",
|
||||||
});
|
data: comicBookMetadata,
|
||||||
}
|
// transformResponse: (r: string) => JSON.parse(r),
|
||||||
};
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
importResult: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
importError: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchVolumeGroups = () => async (dispatch) => {
|
export const fetchVolumeGroups = () => async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
@@ -185,12 +218,11 @@ export const fetchVolumeGroups = () => async (dispatch) => {
|
|||||||
data: response.data,
|
data: response.data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
// Error handling could be added here if needed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const fetchComicVineMatches =
|
export const fetchComicVineMatches =
|
||||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||||
console.log(issueSearchQuery);
|
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -239,7 +271,7 @@ export const fetchComicVineMatches =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
// Error handling could be added here if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -254,24 +286,23 @@ export const fetchComicVineMatches =
|
|||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export const extractComicArchive =
|
export const extractComicArchive =
|
||||||
|
|
||||||
(path: string, options: any): any =>
|
(path: string, options: any): any =>
|
||||||
async (dispatch) => {
|
async (dispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
await axios({
|
await axios({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
filePath: path,
|
filePath: path,
|
||||||
options,
|
options,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Description
|
* Description
|
||||||
@@ -301,26 +332,26 @@ export const searchIssue = (query, options) => async (dispatch) => {
|
|||||||
case "wantedComicsPage":
|
case "wantedComicsPage":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: WANTED_COMICS_FETCHED,
|
type: WANTED_COMICS_FETCHED,
|
||||||
data: response.data.body,
|
data: response.data.hits,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "globalSearchBar":
|
case "globalSearchBar":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
data: response.data.body,
|
data: response.data.hits,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "libraryPage":
|
case "libraryPage":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SS_SEARCH_RESULTS_FETCHED,
|
type: SS_SEARCH_RESULTS_FETCHED,
|
||||||
data: response.data.body,
|
data: response.data.hits,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "volumesPage":
|
case "volumesPage":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: VOLUMES_FETCHED,
|
type: VOLUMES_FETCHED,
|
||||||
data: response.data.body,
|
data: response.data.hits,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -349,4 +380,4 @@ export const analyzeImage =
|
|||||||
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
result: foo.data,
|
result: foo.data,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ export const fetchMetronResource = async (options) => {
|
|||||||
`${METRON_SERVICE_URI}/fetchResource`,
|
`${METRON_SERVICE_URI}/fetchResource`,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
console.log(metronResourceResults);
|
|
||||||
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
|
||||||
const results = metronResourceResults.data.results.map((result) => {
|
const results = metronResourceResults.data.results.map((result) => {
|
||||||
return {
|
return {
|
||||||
label: result.name || result.__str__,
|
label: result.name || result.__str__,
|
||||||
|
|||||||
@@ -3,25 +3,14 @@ import {
|
|||||||
SETTINGS_OBJECT_FETCHED,
|
SETTINGS_OBJECT_FETCHED,
|
||||||
SETTINGS_CALL_IN_PROGRESS,
|
SETTINGS_CALL_IN_PROGRESS,
|
||||||
SETTINGS_DB_FLUSH_SUCCESS,
|
SETTINGS_DB_FLUSH_SUCCESS,
|
||||||
} from "../constants/action-types";
|
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||||
|
} from "../reducers/settings.reducer";
|
||||||
import {
|
import {
|
||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
SETTINGS_SERVICE_BASE_URI,
|
SETTINGS_SERVICE_BASE_URI,
|
||||||
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
} from "../constants/endpoints";
|
} from "../constants/endpoints";
|
||||||
|
|
||||||
export const saveSettings =
|
|
||||||
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
|
|
||||||
const result = await axios({
|
|
||||||
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
|
|
||||||
method: "POST",
|
|
||||||
data: { settingsPayload, settingsObjectId },
|
|
||||||
});
|
|
||||||
dispatch({
|
|
||||||
type: SETTINGS_OBJECT_FETCHED,
|
|
||||||
data: result.data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSettings = (settingsKey?) => async (dispatch) => {
|
export const getSettings = (settingsKey?) => async (dispatch) => {
|
||||||
const result = await axios({
|
const result = await axios({
|
||||||
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
||||||
@@ -67,3 +56,22 @@ export const flushDb = () => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
|
||||||
|
await axios.request({
|
||||||
|
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
|
||||||
|
method: "POST",
|
||||||
|
data: hostInfo,
|
||||||
|
});
|
||||||
|
const qBittorrentClientInfo = await axios.request({
|
||||||
|
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||||
|
data: qBittorrentClientInfo.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};
|
||||||
|
|||||||
@@ -1,635 +0,0 @@
|
|||||||
@import "../../../../node_modules/bulma/bulma.sass";
|
|
||||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
|
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
|
||||||
$bg-color: yellow;
|
|
||||||
$border-color: red;
|
|
||||||
|
|
||||||
$volume-color: #fdecd1;
|
|
||||||
$issue-color: #f2f1f9;
|
|
||||||
$size-8: 0.9rem;
|
|
||||||
$size-9: 0.7rem;
|
|
||||||
$flexSize: 4em;
|
|
||||||
$boxSpacing: 1em;
|
|
||||||
$colorText: #404646;
|
|
||||||
|
|
||||||
.is-size-8 {
|
|
||||||
font-size: $size-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-size-9 {
|
|
||||||
font-size: $size-9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-tag {
|
|
||||||
align-items: center;
|
|
||||||
background-color: #fff6de;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #4a4a4a;
|
|
||||||
display: inline-flex;
|
|
||||||
font-size: $size-9;
|
|
||||||
height: 1.5em;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding-left: 0.55em;
|
|
||||||
padding-right: 0.55em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// global style overrides
|
|
||||||
|
|
||||||
pre {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin-top: 2em;
|
|
||||||
}
|
|
||||||
.app {
|
|
||||||
font-family: helvetica, arial, sans-serif;
|
|
||||||
padding: 2em;
|
|
||||||
border: 5px solid $border-color;
|
|
||||||
|
|
||||||
p {
|
|
||||||
background-color: $bg-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Navbar
|
|
||||||
.navbar {
|
|
||||||
border-bottom: 1px solid #f2f1f9;
|
|
||||||
.download-progress-meter {
|
|
||||||
margin-left: -300px;
|
|
||||||
min-width: 500px;
|
|
||||||
}
|
|
||||||
.airdcpp-status {
|
|
||||||
min-width: 300px;
|
|
||||||
line-height: 1.7rem;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: #454a59;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: #454a59;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulsating-circle {
|
|
||||||
position: relative;
|
|
||||||
left: -120%;
|
|
||||||
top: 20%;
|
|
||||||
transform: translateX(-50%) translateY(-50%);
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
width: 300%;
|
|
||||||
height: 300%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-left: -100%;
|
|
||||||
margin-top: -100%;
|
|
||||||
border-radius: 45px;
|
|
||||||
background-color: #01a4e9;
|
|
||||||
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: green;
|
|
||||||
border-radius: 15px;
|
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.33);
|
|
||||||
}
|
|
||||||
80%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-item.is-mega {
|
|
||||||
position: static;
|
|
||||||
|
|
||||||
.is-mega-menu-title {
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding: 0.375rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
|
|
||||||
// slick slider overrides
|
|
||||||
.slick-slider {
|
|
||||||
margin-left: -10px;
|
|
||||||
.slick-list {
|
|
||||||
padding: 0 0px 15px 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.recent-comics-container {
|
|
||||||
display: -webkit-box; /* Not needed if autoprefixing */
|
|
||||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
|
||||||
display: flex;
|
|
||||||
margin-left: -22px; /* gutter size offset */
|
|
||||||
width: auto;
|
|
||||||
|
|
||||||
.recent-comics-column {
|
|
||||||
padding-left: 22px; /* gutter size */
|
|
||||||
background-clip: padding-box;
|
|
||||||
& > div {
|
|
||||||
/* change div to reference your elements you put in <Masonry> */
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.volumes-container {
|
|
||||||
.stack {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow:
|
|
||||||
/* The top layer shadow */ 0 -1px 1px rgba(0, 0, 0, 0.15),
|
|
||||||
/* The second layer */ 0 -10px 0 -5px #eee,
|
|
||||||
/* The second layer shadow */ 0 -10px 1px -4px rgba(0, 0, 0, 0.15),
|
|
||||||
/* The third layer */ 0 -20px 0 -10px #eee,
|
|
||||||
/* The third layer shadow */ 0 -20px 1px -9px rgba(0, 0, 0, 0.15);
|
|
||||||
img {
|
|
||||||
height: auto;
|
|
||||||
border-top-left-radius: 0.5rem;
|
|
||||||
border-top-right-radius: 0.5rem;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
.stack-title {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
margin: -5px 0 0 0;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-bottom-left-radius: 0.25rem;
|
|
||||||
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
|
||||||
border-bottom-right-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.volumes-grid {
|
|
||||||
display: -webkit-box; /* Not needed if autoprefixing */
|
|
||||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
|
||||||
display: flex;
|
|
||||||
margin-left: -30px; /* gutter size offset */
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.volumes-grid-column {
|
|
||||||
padding-left: 22px; /* gutter size */
|
|
||||||
background-clip: padding-box;
|
|
||||||
& > div {
|
|
||||||
/* change div to reference your elements you put in <Masonry> */
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.min {
|
|
||||||
overflow: visible;
|
|
||||||
margin: auto;
|
|
||||||
.tag__custom {
|
|
||||||
height: auto !important;
|
|
||||||
padding: 0.3rem;
|
|
||||||
white-space: unset !important;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #effaf5;
|
|
||||||
color: #257953;
|
|
||||||
}
|
|
||||||
.tags {
|
|
||||||
display: inline;
|
|
||||||
margin-right: 5px;
|
|
||||||
margin-left: 5px;
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
border-radius: 0.4em;
|
|
||||||
margin: 10px 0 10px 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.generic-card {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #fff;
|
|
||||||
border-top-left-radius: 0.4rem;
|
|
||||||
border-top-right-radius: 0.4rem;
|
|
||||||
border-bottom-left-radius: 0.4rem;
|
|
||||||
border-bottom-right-radius: 0.4rem;
|
|
||||||
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
|
||||||
|
|
||||||
.green-border {
|
|
||||||
border: 1px dotted #168b64;
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
width: 100px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.partial-rounded-card-image {
|
|
||||||
figure {
|
|
||||||
display: flex;
|
|
||||||
img {
|
|
||||||
border-top-left-radius: 0.4rem;
|
|
||||||
border-top-right-radius: 0.4rem;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rounded-card-image {
|
|
||||||
figure {
|
|
||||||
display: flex;
|
|
||||||
img {
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.card-content {
|
|
||||||
.card-title {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
.custom-icon,
|
|
||||||
i {
|
|
||||||
margin: 4px 4px 4px 0;
|
|
||||||
}
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.card-container {
|
|
||||||
// display: grid;
|
|
||||||
// grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
// column-gap: 0.5em;
|
|
||||||
// row-gap: 1.2em;
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
|
|
||||||
.partial-rounded-card-image {
|
|
||||||
img {
|
|
||||||
border-top-left-radius: 0.4rem;
|
|
||||||
border-top-right-radius: 0.4rem;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rounded-card-image {
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-horizontal {
|
|
||||||
// margin: $boxSpacing / 2;
|
|
||||||
border-radius: 1.5em;
|
|
||||||
height: $flexSize;
|
|
||||||
max-width: $flexSize * 3;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
.card-image {
|
|
||||||
// leaving this here... for posterity
|
|
||||||
img.image {
|
|
||||||
border-top-left-radius: 8px;
|
|
||||||
border-bottom-left-radius: 8px;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
height: 100%;
|
|
||||||
max-width: $flexSize * 1.3;
|
|
||||||
object-fit: cover;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
img.cropped-image {
|
|
||||||
width: 70px;
|
|
||||||
border-top-left-radius: 8px;
|
|
||||||
border-bottom-left-radius: 8px;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: 100% 0;
|
|
||||||
// flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.card-content {
|
|
||||||
align-self: top;
|
|
||||||
flex: 1;
|
|
||||||
padding-left: 0.7em;
|
|
||||||
padding-top: 0.4em;
|
|
||||||
padding-bottom: 0em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// raw file details
|
|
||||||
.raw-file-details {
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: beige;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comic-viewer {
|
|
||||||
border: 1px solid red;
|
|
||||||
}
|
|
||||||
|
|
||||||
// comicvine metadata
|
|
||||||
.comicvine-metadata {
|
|
||||||
background-color: #f2f1f9;
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.issue-metadata {
|
|
||||||
background-color: #fbffee;
|
|
||||||
padding: 0.8em;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
.name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #4a4f50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comicInfo-metadata {
|
|
||||||
background-color: #f7ebdd;
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comic Detail
|
|
||||||
.comic-detail {
|
|
||||||
dl {
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
.airdcpp-text {
|
|
||||||
margin: 0 0 0 0.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirDC++ search results
|
|
||||||
.dupe-search-result {
|
|
||||||
background: lavender;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
|
||||||
.search {
|
|
||||||
.main-search-bar {
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid #999;
|
|
||||||
border-radius: 0;
|
|
||||||
outline: 0;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Library
|
|
||||||
.header-area {
|
|
||||||
width: 100%;
|
|
||||||
padding: 25px 0 15px 0;
|
|
||||||
position: sticky;
|
|
||||||
z-index:9999;
|
|
||||||
background: #fffffc;
|
|
||||||
top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library {
|
|
||||||
.table-controls {
|
|
||||||
background: #fffffc;
|
|
||||||
justify-content: space-between;
|
|
||||||
position: sticky;
|
|
||||||
top: 126px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
.pagination {
|
|
||||||
margin: 0;
|
|
||||||
background: #fffffc;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: separate;
|
|
||||||
width: 100%;
|
|
||||||
thead {
|
|
||||||
position: sticky;
|
|
||||||
top: 250px;
|
|
||||||
z-index: 1;
|
|
||||||
background: #fffffc;
|
|
||||||
min-height: 130px;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
td {
|
|
||||||
border: 0 none;
|
|
||||||
.card {
|
|
||||||
margin: 8px 0 7px 0;
|
|
||||||
.name {
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody {
|
|
||||||
padding: 10px 0 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comic Detail
|
|
||||||
.control-palette {
|
|
||||||
background-color: #fff6de;
|
|
||||||
display: inline-block;
|
|
||||||
i {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
// padding: 1.5rem 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// airdcpp downloads tab
|
|
||||||
.tabs {
|
|
||||||
.download-icon-labels {
|
|
||||||
.downloads-count {
|
|
||||||
margin: 0 1em -1px 0.4em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.download-tab-name {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// drawer content padding override
|
|
||||||
.slide-pane__content {
|
|
||||||
padding: 24px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-pane__header {
|
|
||||||
margin-top: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comic-vine-match-drawer {
|
|
||||||
// comic detail drawer
|
|
||||||
|
|
||||||
.search-criteria-card {
|
|
||||||
width: 100%;
|
|
||||||
.card-content {
|
|
||||||
padding: 10px;
|
|
||||||
.ant-divider-horizontal {
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin: 5px 0 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume detail
|
|
||||||
.volume-details {
|
|
||||||
.is-volume-related {
|
|
||||||
$tag-background-color: $volume-color;
|
|
||||||
}
|
|
||||||
.issues-container {
|
|
||||||
display: -webkit-box; /* Not needed if autoprefixing */
|
|
||||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
|
||||||
display: flex;
|
|
||||||
margin-left: -10px; /* gutter size offset */
|
|
||||||
width: auto;
|
|
||||||
.issues-column {
|
|
||||||
max-width: 102px;
|
|
||||||
margin: 10px;
|
|
||||||
background-clip: padding-box;
|
|
||||||
& > div {
|
|
||||||
/* change div to reference your elements you put in <Masonry> */
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Potential issue matches in library slideout panel
|
|
||||||
.potential-matches-container {
|
|
||||||
.potential-issue-match {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: beige;
|
|
||||||
padding: 10px;
|
|
||||||
pre {
|
|
||||||
padding: 5px;
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
white-space: pre-wrap; /* Since CSS 2.1 */
|
|
||||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
|
||||||
white-space: -pre-wrap; /* Opera 4-6 */
|
|
||||||
white-space: -o-pre-wrap; /* Opera 7 */
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
.generic-card {
|
|
||||||
max-width: 90px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// comicvine search results
|
|
||||||
.search-results-container {
|
|
||||||
margin: 15px 0 0 0;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.search-result {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f2f1f9;
|
|
||||||
.cover-image {
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.search-result-details {
|
|
||||||
.score {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.volume-information {
|
|
||||||
margin-top: -2.5em;
|
|
||||||
width: 80%;
|
|
||||||
background: #fdecd1;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.vertical-line {
|
|
||||||
position: relative;
|
|
||||||
top: -25px;
|
|
||||||
left: 1.5rem;
|
|
||||||
border: 2px dotted #ccc;
|
|
||||||
width: 20px;
|
|
||||||
min-height: 35px;
|
|
||||||
|
|
||||||
border-color: transparent transparent #f3a22d #f3a22d;
|
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Library grid
|
|
||||||
.my-masonry-grid {
|
|
||||||
display: -webkit-box; /* Not needed if autoprefixing */
|
|
||||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
|
||||||
display: flex;
|
|
||||||
margin-left: -30px; /* gutter size offset */
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.my-masonry-grid_column {
|
|
||||||
padding-left: 30px; /* gutter size */
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-masonry-grid_column > div {
|
|
||||||
/* change div to reference your elements you put in <Masonry> */
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// progress
|
|
||||||
.progress-indicator-container {
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.indicator {
|
|
||||||
padding: 5px;
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
|
||||||
import { Form, Field } from "react-final-form";
|
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
|
||||||
import Select from "react-select";
|
|
||||||
import { saveSettings } from "../../actions/settings.actions";
|
|
||||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
|
||||||
|
|
||||||
export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const [hubList, setHubList] = useState([]);
|
|
||||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
|
||||||
const {
|
|
||||||
airDCPPState: { settings, socket },
|
|
||||||
} = airDCPPConfiguration;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (!isEmpty(settings)) {
|
|
||||||
const hubs = await socket.get(`hubs`);
|
|
||||||
const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
|
|
||||||
value: hub_url,
|
|
||||||
label: identity.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setHubList(hubSelectionOptions);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = (values) => {
|
|
||||||
if (!isUndefined(values.hubs)) {
|
|
||||||
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validate = async () => {};
|
|
||||||
|
|
||||||
const SelectAdapter = ({ input, ...rest }) => {
|
|
||||||
return <Select {...input} {...rest} isClearable isMulti />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validate={validate}
|
|
||||||
render={({ handleSubmit }) => (
|
|
||||||
<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.
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="label">AirDC++ Host</label>
|
|
||||||
<div className="control">
|
|
||||||
<Field
|
|
||||||
name="hubs"
|
|
||||||
component={SelectAdapter}
|
|
||||||
className="basic-multi-select"
|
|
||||||
placeholder="Select Hubs to Search Against"
|
|
||||||
options={hubList}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" className="button is-primary">
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-4">
|
|
||||||
<article className="message is-warning">
|
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
|
||||||
Your selection in the dropdown <strong>will replace</strong> the
|
|
||||||
existing selection.
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div className="box mt-3">
|
|
||||||
<h6>Selected hubs</h6>
|
|
||||||
{settings.directConnect.client.hubs.map(({ value, label }) => (
|
|
||||||
<div key={value}>
|
|
||||||
<div>{label}</div>
|
|
||||||
<span className="is-size-7">{value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AirDCPPHubsForm;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React, { ReactElement } from "react";
|
|
||||||
|
|
||||||
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
|
|
||||||
const { settings } = settingsObject;
|
|
||||||
console.log(settings);
|
|
||||||
return (
|
|
||||||
<div className="mt-4 is-clearfix">
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-content">
|
|
||||||
<span className="tag is-pulled-right is-primary">Connected</span>
|
|
||||||
<div className="content is-size-7">
|
|
||||||
<dl>
|
|
||||||
<dt>{settings._id}</dt>
|
|
||||||
<dt>Client version: {settings.system_info.client_version}</dt>
|
|
||||||
<dt>Hostname: {settings.system_info.hostname}</dt>
|
|
||||||
<dt>Platform: {settings.system_info.platform}</dt>
|
|
||||||
|
|
||||||
<dt>Username: {settings.user.username}</dt>
|
|
||||||
|
|
||||||
<dt>Active Sessions: {settings.user.active_sessions}</dt>
|
|
||||||
<dt>
|
|
||||||
Permissions:{" "}
|
|
||||||
<pre>
|
|
||||||
{JSON.stringify(settings.user.permissions, undefined, 2)}
|
|
||||||
</pre>
|
|
||||||
</dt>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AirDCPPSettingsConfirmation;
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import React, { ReactElement, useCallback, useContext } from "react";
|
|
||||||
import { Form, Field } from "react-final-form";
|
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
import { saveSettings, deleteSettings } from "../../actions/settings.actions";
|
|
||||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
|
||||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
|
||||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
|
||||||
|
|
||||||
export const AirDCPPSettingsForm = (): ReactElement => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const airDCPPSettings = useContext(AirDCPPSocketContext);
|
|
||||||
|
|
||||||
const hostValidator = (hostname: string): string | null => {
|
|
||||||
const hostnameRegex = /[\W]+/gm;
|
|
||||||
try {
|
|
||||||
if (!isUndefined(hostname)) {
|
|
||||||
const matches = hostname.match(hostnameRegex);
|
|
||||||
return (isNil(matches) && matches.length !== 0) ? hostname : "Invalid hostname; it should not contain special characters";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = useCallback(async (values) => {
|
|
||||||
try {
|
|
||||||
airDCPPSettings.setSettings(values);
|
|
||||||
dispatch(
|
|
||||||
saveSettings({
|
|
||||||
host: values,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
const removeSettings = useCallback(async () => {
|
|
||||||
airDCPPSettings.setSettings({});
|
|
||||||
dispatch(deleteSettings());
|
|
||||||
}, []);
|
|
||||||
const validate = async () => { };
|
|
||||||
const initFormData = !isUndefined(
|
|
||||||
airDCPPSettings.airDCPPState.settings.directConnect,
|
|
||||||
)
|
|
||||||
? airDCPPSettings.airDCPPState.settings.directConnect.client.host
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validate={validate}
|
|
||||||
initialValues={initFormData}
|
|
||||||
render={({ handleSubmit }) => (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<h2>AirDC++ Connection Information</h2>
|
|
||||||
<label className="label">AirDC++ Hostname</label>
|
|
||||||
<div className="field has-addons">
|
|
||||||
<p className="control">
|
|
||||||
<span className="select">
|
|
||||||
<Field name="protocol" component="select">
|
|
||||||
<option>Protocol</option>
|
|
||||||
<option value="http">http://</option>
|
|
||||||
<option value="https">https://</option>
|
|
||||||
</Field>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<div className="control is-expanded">
|
|
||||||
<Field
|
|
||||||
name="hostname"
|
|
||||||
validate={hostValidator}>
|
|
||||||
{({ input, meta }) => (
|
|
||||||
<div>
|
|
||||||
<input {...input} type="text" placeholder="AirDC++ hostname" className="input" />
|
|
||||||
{meta.error && meta.touched && <span className="is-size-7 has-text-danger">{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<p className="control">
|
|
||||||
<Field
|
|
||||||
name="port"
|
|
||||||
component="input"
|
|
||||||
className="input"
|
|
||||||
placeholder="AirDC++ port"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<div className="is-clearfix">
|
|
||||||
<label className="label">Credentials</label>
|
|
||||||
</div>
|
|
||||||
<div className="field-body">
|
|
||||||
<div className="field">
|
|
||||||
<p className="control is-expanded has-icons-left">
|
|
||||||
<Field
|
|
||||||
name="username"
|
|
||||||
component="input"
|
|
||||||
className="input"
|
|
||||||
placeholder="Username"
|
|
||||||
/>
|
|
||||||
<span className="icon is-small is-left">
|
|
||||||
<i className="fa-solid fa-user-ninja"></i>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<p className="control is-expanded has-icons-left has-icons-right">
|
|
||||||
<Field
|
|
||||||
name="password"
|
|
||||||
component="input"
|
|
||||||
type="password"
|
|
||||||
className="input"
|
|
||||||
placeholder="Password"
|
|
||||||
/>
|
|
||||||
<span className="icon is-small is-left">
|
|
||||||
<i className="fa-solid fa-lock"></i>
|
|
||||||
</span>
|
|
||||||
<span className="icon is-small is-right">
|
|
||||||
<i className="fas fa-check"></i>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="field is-grouped">
|
|
||||||
<p className="control">
|
|
||||||
<button type="submit" className="button is-primary">
|
|
||||||
{!isEmpty(initFormData) ? "Update" : "Save"}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
|
||||||
<AirDCPPSettingsConfirmation
|
|
||||||
settings={airDCPPSettings.airDCPPState.socketConnectionInformation}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
|
||||||
<p className="control mt-4">
|
|
||||||
<button className="button is-danger" onClick={removeSettings}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AirDCPPSettingsForm;
|
|
||||||
@@ -1,139 +1,21 @@
|
|||||||
import React, { ReactElement, useContext, useEffect } from "react";
|
import React, { ReactElement, useEffect } from "react";
|
||||||
import Dashboard from "./Dashboard/Dashboard";
|
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 Import from "./Import";
|
|
||||||
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
|
|
||||||
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
|
|
||||||
import LibraryGrid from "./Library/LibraryGrid";
|
|
||||||
import Search from "./Search";
|
|
||||||
import Settings from "./Settings";
|
|
||||||
import VolumeDetail from "./VolumeDetail/VolumeDetail";
|
|
||||||
import Downloads from "./Downloads/Downloads";
|
|
||||||
|
|
||||||
import { Routes, Route } from "react-router-dom";
|
|
||||||
import Navbar from "./Navbar";
|
|
||||||
import "../assets/scss/App.scss";
|
|
||||||
import {
|
|
||||||
AirDCPPSocketContextProvider,
|
|
||||||
AirDCPPSocketContext,
|
|
||||||
} from "../context/AirDCPPSocket";
|
|
||||||
import { isEmpty, isUndefined } from "lodash";
|
|
||||||
import {
|
|
||||||
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
|
||||||
LS_SINGLE_IMPORT,
|
|
||||||
} from "../constants/action-types";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Method that initializes an AirDC++ socket connection
|
|
||||||
* 1. Initializes event listeners for download init, tick and complete events
|
|
||||||
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
|
|
||||||
* @returns void
|
|
||||||
*/
|
|
||||||
const AirDCPPSocketComponent = (): ReactElement => {
|
|
||||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeAirDCPPEventListeners = async () => {
|
|
||||||
if (
|
|
||||||
!isUndefined(airDCPPConfiguration.airDCPPState) &&
|
|
||||||
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
|
|
||||||
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
|
|
||||||
) {
|
|
||||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
|
||||||
"queue",
|
|
||||||
"queue_bundle_added",
|
|
||||||
async (data) => {
|
|
||||||
console.log("JEMEN:", data);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// download tick listener
|
|
||||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
|
||||||
`queue`,
|
|
||||||
"queue_bundle_tick",
|
|
||||||
async (downloadProgressData) => {
|
|
||||||
dispatch({
|
|
||||||
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
|
||||||
downloadProgressData,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// download complete listener
|
|
||||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
|
||||||
`queue`,
|
|
||||||
"queue_bundle_status",
|
|
||||||
async (bundleData) => {
|
|
||||||
let count = 0;
|
|
||||||
if (bundleData.status.completed && bundleData.status.downloaded) {
|
|
||||||
// dispatch the action for raw import, with the metadata
|
|
||||||
if (count < 1) {
|
|
||||||
console.log(`[AirDCPP]: Download complete.`);
|
|
||||||
dispatch({
|
|
||||||
type: LS_SINGLE_IMPORT,
|
|
||||||
meta: { remote: true },
|
|
||||||
data: bundleData,
|
|
||||||
});
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"[AirDCPP]: Listener registered - listening to queue bundle changes",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"[AirDCPP]: Listener registered - listening to transfer completion",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
initializeAirDCPPEventListeners();
|
|
||||||
}, [airDCPPConfiguration]);
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
export const App = (): ReactElement => {
|
export const App = (): ReactElement => {
|
||||||
|
useEffect(() => {
|
||||||
|
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AirDCPPSocketContextProvider>
|
<>
|
||||||
<div>
|
<Navbar2 />
|
||||||
<AirDCPPSocketComponent />
|
<Outlet />
|
||||||
<Navbar />
|
<ToastContainer stacked hideProgressBar />
|
||||||
<Routes>
|
</>
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/import" element={<Import path={"./comics"} />} />
|
|
||||||
<Route
|
|
||||||
path="/library"
|
|
||||||
element={<TabulatedContentContainer category="library" />}
|
|
||||||
/>
|
|
||||||
<Route path="/library-grid" element={<LibraryGrid />} />
|
|
||||||
<Route path="/downloads" element={<Downloads data={{}} />} />
|
|
||||||
<Route path="/search" element={<Search />} />
|
|
||||||
<Route
|
|
||||||
path={"/comic/details/:comicObjectId"}
|
|
||||||
element={<ComicDetailContainer />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={"/volume/details/:comicObjectId"}
|
|
||||||
element={<VolumeDetail />}
|
|
||||||
/>
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route
|
|
||||||
path="/pull-list/all"
|
|
||||||
element={<TabulatedContentContainer category="pullList" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/wanted/all"
|
|
||||||
element={<TabulatedContentContainer category="wanted" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/volumes/all"
|
|
||||||
element={<TabulatedContentContainer category="volumes" />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</AirDCPPSocketContextProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
|
||||||
import {
|
|
||||||
removeLeadingPeriod,
|
|
||||||
escapePoundSymbol,
|
|
||||||
} from "../shared/utils/formatting.utils";
|
|
||||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
|
||||||
import ellipsize from "ellipsize";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
|
||||||
mongoObjId?: number;
|
|
||||||
hasTitle: boolean;
|
|
||||||
title?: string;
|
|
||||||
isHorizontal: boolean;
|
|
||||||
}
|
|
||||||
interface IState {}
|
|
||||||
|
|
||||||
class Card extends React.Component<IProps, IState> {
|
|
||||||
constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
public drawCoverCard = (
|
|
||||||
metadata: IExtractedComicBookCoverFile,
|
|
||||||
): JSX.Element => {
|
|
||||||
const encodedFilePath = encodeURI(
|
|
||||||
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
|
||||||
);
|
|
||||||
const filePath = escapePoundSymbol(encodedFilePath);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="card generic-card">
|
|
||||||
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
|
||||||
<div className="card-image">
|
|
||||||
<figure className="image">
|
|
||||||
<img src={filePath} alt="Placeholder image" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
{this.props.hasTitle && (
|
|
||||||
<div className="card-content">
|
|
||||||
<ul>
|
|
||||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
|
||||||
<li className="has-text-weight-semibold">
|
|
||||||
{ellipsize(metadata.name, 18)}
|
|
||||||
</li>
|
|
||||||
</Link>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isUndefined(this.props.comicBookCoversMetadata) &&
|
|
||||||
!isEmpty(this.props.comicBookCoversMetadata) &&
|
|
||||||
this.drawCoverCard(this.props.comicBookCoversMetadata)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Card;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import React, { ReactElement } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { isEmpty, isNil } from "lodash";
|
|
||||||
|
|
||||||
interface ICardProps {
|
|
||||||
orientation: string;
|
|
||||||
imageUrl: string;
|
|
||||||
hasDetails: boolean;
|
|
||||||
title?: PropTypes.ReactElementLike | null;
|
|
||||||
children?: PropTypes.ReactNodeLike;
|
|
||||||
borderColorClass?: string;
|
|
||||||
backgroundColor?: string;
|
|
||||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
||||||
cardContainerStyle?: PropTypes.object;
|
|
||||||
imageStyle?: PropTypes.object;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCard = (props): ReactElement => {
|
|
||||||
switch (props.orientation) {
|
|
||||||
case "horizontal":
|
|
||||||
return (
|
|
||||||
<div className="card-container">
|
|
||||||
<div className="card generic-card">
|
|
||||||
<div className="is-horizontal">
|
|
||||||
<div className="card-image">
|
|
||||||
<img
|
|
||||||
style={props.imageStyle}
|
|
||||||
src={props.imageUrl}
|
|
||||||
alt="Placeholder image"
|
|
||||||
className="cropped-image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{props.hasDetails && (
|
|
||||||
<div className="card-content">{props.children}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "vertical":
|
|
||||||
return (
|
|
||||||
<div onClick={props.onClick}>
|
|
||||||
<div className="generic-card" style={props.cardContainerStyle}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
!isNil(props.borderColorClass)
|
|
||||||
? `${props.borderColorClass}`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
props.hasDetails
|
|
||||||
? "partial-rounded-card-image"
|
|
||||||
: "rounded-card-image"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<figure>
|
|
||||||
<img
|
|
||||||
src={props.imageUrl}
|
|
||||||
style={props.imageStyle}
|
|
||||||
alt="Placeholder image"
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
{props.hasDetails && (
|
|
||||||
<div
|
|
||||||
className="card-content"
|
|
||||||
style={{ backgroundColor: props.backgroundColor }}
|
|
||||||
>
|
|
||||||
{!isNil(props.title) ? (
|
|
||||||
<div className="card-title is-size-8 is-family-secondary">
|
|
||||||
{props.title}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Card = (props: ICardProps): ReactElement => {
|
|
||||||
return renderCard(props);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Card;
|
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
ReactElement,
|
ReactElement,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||||
search,
|
|
||||||
downloadAirDCPPItem,
|
|
||||||
getBundlesForComic,
|
|
||||||
} from "../../actions/airdcpp.actions";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { difference } from "../../shared/utils/object.utils";
|
||||||
import { isEmpty, isNil, map } from "lodash";
|
import { isEmpty, isNil, map } from "lodash";
|
||||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
interface IAcquisitionPanelProps {
|
interface IAcquisitionPanelProps {
|
||||||
query: any;
|
query: any;
|
||||||
@@ -27,253 +28,331 @@ interface IAcquisitionPanelProps {
|
|||||||
export const AcquisitionPanel = (
|
export const AcquisitionPanel = (
|
||||||
props: IAcquisitionPanelProps,
|
props: IAcquisitionPanelProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
|
const socketRef = useRef<Socket>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [dcppQuery, setDcppQuery] = useState({});
|
||||||
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
||||||
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
||||||
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
||||||
|
|
||||||
|
const { comicObjectId } = props;
|
||||||
const issueName = props.query.issue.name || "";
|
const issueName = props.query.issue.name || "";
|
||||||
// const { settings } = props;
|
|
||||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
|
|
||||||
// Selectors for picking state
|
useEffect(() => {
|
||||||
const airDCPPSearchResults = useSelector((state: RootState) => {
|
const socket = useStore.getState().getSocket("manual");
|
||||||
return state.airdcpp.searchResults;
|
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 isAirDCPPSearchInProgress = useSelector(
|
|
||||||
(state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
|
|
||||||
);
|
|
||||||
const searchInfo = useSelector(
|
|
||||||
(state: RootState) => state.airdcpp.searchInfo,
|
|
||||||
);
|
|
||||||
const searchInstance: SearchInstance = useSelector(
|
|
||||||
(state: RootState) => state.airdcpp.searchInstance,
|
|
||||||
);
|
|
||||||
|
|
||||||
// const settings = useSelector((state: RootState) => state.settings.data);
|
const { data: hubs } = useQuery({
|
||||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
queryKey: ["hubs"],
|
||||||
|
queryFn: async () =>
|
||||||
const dispatch = useDispatch();
|
await axios({
|
||||||
const [dcppQuery, setDcppQuery] = useState({});
|
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
host: settings?.data.directConnect?.client?.host,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
|
const dcppSearchQuery = {
|
||||||
// AirDC++ search query
|
query: {
|
||||||
const dcppSearchQuery = {
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
query: {
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
},
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
hub_urls: map(hubs?.data, (item) => item.value),
|
||||||
},
|
priority: 5,
|
||||||
hub_urls: map(
|
};
|
||||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
setDcppQuery(dcppSearchQuery);
|
||||||
(item) => item.value,
|
}, [hubs, sanitizedIssueName]);
|
||||||
),
|
|
||||||
priority: 5,
|
|
||||||
};
|
|
||||||
setDcppQuery(dcppSearchQuery);
|
|
||||||
}
|
|
||||||
}, [airDCPPConfiguration]);
|
|
||||||
|
|
||||||
const getDCPPSearchResults = useCallback(
|
const search = async (searchData: any) => {
|
||||||
async (searchQuery) => {
|
setAirDCPPSearchResults([]);
|
||||||
const manualQuery = {
|
socketRef.current?.emit("call", "socket.search", {
|
||||||
query: {
|
query: searchData,
|
||||||
pattern: `${searchQuery.issueName}`,
|
namespace: "/manual",
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
config: {
|
||||||
},
|
protocol: `ws`,
|
||||||
hub_urls: map(
|
hostname: `192.168.1.119:5600`,
|
||||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
username: `admin`,
|
||||||
(item) => item.value,
|
password: `password`,
|
||||||
),
|
},
|
||||||
priority: 5,
|
});
|
||||||
};
|
};
|
||||||
dispatch(
|
|
||||||
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
|
const download = async (
|
||||||
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
searchInstanceId: Number,
|
||||||
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
resultId: String,
|
||||||
}),
|
comicObjectId: String,
|
||||||
);
|
name: String,
|
||||||
},
|
size: Number,
|
||||||
[dispatch, airDCPPConfiguration],
|
type: any,
|
||||||
);
|
config: any,
|
||||||
|
): Promise<void> => {
|
||||||
|
socketRef.current?.emit(
|
||||||
|
"call",
|
||||||
|
"socket.download",
|
||||||
|
{
|
||||||
|
searchInstanceId,
|
||||||
|
resultId,
|
||||||
|
comicObjectId,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
config,
|
||||||
|
},
|
||||||
|
(data: any) => {
|
||||||
|
// Download initiated
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDCPPSearchResults = async (searchQuery) => {
|
||||||
|
const manualQuery = {
|
||||||
|
query: {
|
||||||
|
pattern: `${searchQuery.issueName}`,
|
||||||
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
|
},
|
||||||
|
hub_urls: [hubs?.data[0].hub_url],
|
||||||
|
priority: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
search(manualQuery);
|
||||||
|
};
|
||||||
|
|
||||||
// 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}`,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[airDCPPConfiguration],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="comic-detail columns">
|
<div className="mt-5 mb-3">
|
||||||
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
|
{!isEmpty(hubs?.data) ? (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={getDCPPSearchResults}
|
onSubmit={getDCPPSearchResults}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
issueName,
|
issueName,
|
||||||
}}
|
}}
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
<form
|
<form onSubmit={handleSubmit}>
|
||||||
onSubmit={handleSubmit}
|
<Field name="issueName">
|
||||||
className="column is-three-quarters"
|
{({ input, meta }) => {
|
||||||
>
|
return (
|
||||||
<div className="box search">
|
<div className="max-w-fit">
|
||||||
<div className="columns">
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||||
<Field name="issueName">
|
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||||
{({ input, meta }) => {
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
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">
|
|
||||||
Use this to perform a manual search.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<input
|
||||||
}}
|
{...input}
|
||||||
</Field>
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="column">
|
<button
|
||||||
<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"
|
type="submit"
|
||||||
className={
|
>
|
||||||
isAirDCPPSearchInProgress
|
<div className="flex flex-row">
|
||||||
? "button is-loading is-warning"
|
Search DC++
|
||||||
: "button"
|
<div className="h-5 w-5 ml-2">
|
||||||
}
|
<img
|
||||||
>
|
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||||
<span className="icon is-small">
|
className="h-5 w-5"
|
||||||
<img src="/img/airdcpp_logo.svg" />
|
/>
|
||||||
</span>
|
</div>
|
||||||
<span className="airdcpp-text">Search on AirDC++</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
<article
|
||||||
<article className="message is-info">
|
role="alert"
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
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"
|
||||||
AirDC++ is not configured. Please configure it in{" "}
|
>
|
||||||
<code>Settings</code>.
|
No AirDC++ hub configured. Please configure it in{" "}
|
||||||
</div>
|
<code>Settings > AirDC++ > Hubs</code>.
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* configured hub */}
|
||||||
{/* AirDC++ search instance details */}
|
{!isEmpty(hubs?.data) && (
|
||||||
{!isNil(searchInfo) && !isNil(searchInstance) && (
|
<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">
|
||||||
<div className="columns">
|
<span className="pr-1 pt-1">
|
||||||
<div className="column is-one-quarter is-size-7">
|
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||||
<div className="card">
|
</span>
|
||||||
<div className="card-content">
|
{hubs && hubs?.data[0].hub_url}
|
||||||
<dl>
|
</span>
|
||||||
<dt>
|
|
||||||
<div className="tags mb-1">
|
|
||||||
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
|
|
||||||
({ value }) => (
|
|
||||||
<span className="tag is-warning" key={value}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</dt>
|
|
||||||
<dt>
|
|
||||||
Query:{" "}
|
|
||||||
<span className="has-text-weight-semibold">
|
|
||||||
{searchInfo.query.pattern}
|
|
||||||
</span>
|
|
||||||
</dt>
|
|
||||||
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
|
|
||||||
<dd>File type: {searchInfo.query.file_type}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="column is-one-quarter is-size-7">
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-content">
|
|
||||||
<dl>
|
|
||||||
<dt>Search Instance: {searchInstance.id}</dt>
|
|
||||||
<dt>Owned by {searchInstance.owner}</dt>
|
|
||||||
<dd>Expires in: {searchInstance.expires_in}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AirDC++ results */}
|
{/* AirDC++ results */}
|
||||||
<div className="columns">
|
<div className="">
|
||||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||||
<div className="column">
|
<div className="overflow-x-auto max-w-full mt-6">
|
||||||
<table className="table">
|
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||||
<th>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">
|
||||||
<th>Type</th>
|
Name
|
||||||
<th>Slots</th>
|
</th>
|
||||||
<th>Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
{map(
|
||||||
return (
|
airDCPPSearchResults,
|
||||||
|
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={idx}
|
key={idx}
|
||||||
className={
|
className={
|
||||||
!isNil(result.dupe) ? "dupe-search-result" : ""
|
!isNil(dupe)
|
||||||
|
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
|
||||||
|
: "border-b border-gray-200 dark:border-slate-700 text-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td>
|
{/* NAME */}
|
||||||
|
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
{result.type.id === "directory" ? (
|
{type.id === "directory" && (
|
||||||
<i className="fas fa-folder"></i>
|
<i className="fas fa-folder mr-1"></i>
|
||||||
) : null}{" "}
|
)}
|
||||||
{ellipsize(result.name, 70)}
|
{ellipsize(name, 45)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<dl>
|
<dl>
|
||||||
<dd>
|
<dd>
|
||||||
<div className="tags">
|
<div className="inline-flex flex-wrap gap-1">
|
||||||
{!isNil(result.dupe) ? (
|
{!isNil(dupe) && (
|
||||||
<span className="tag is-warning">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">
|
||||||
) : null}
|
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||||
<span className="tag is-light is-info">
|
Dupe
|
||||||
{result.users.user.nicks}
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||||
|
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||||
|
{users.user.nicks}
|
||||||
</span>
|
</span>
|
||||||
{result.users.user.flags.map((flag, idx) => (
|
{users.user.flags.map((flag, idx) => (
|
||||||
<span className="tag is-light" key={idx}>
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
|
||||||
|
>
|
||||||
|
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||||
{flag}
|
{flag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -281,52 +360,77 @@ export const AcquisitionPanel = (
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span className="tag is-light is-info">
|
{/* TYPE */}
|
||||||
{result.type.id === "directory"
|
<td className="px-2 py-3">
|
||||||
? "directory"
|
<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">
|
||||||
: result.type.str}
|
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||||
|
{type.str}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<div className="tags has-addons">
|
{/* SLOTS */}
|
||||||
<span className="tag is-success">
|
<td className="px-2 py-3">
|
||||||
{result.slots.free} free
|
<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">
|
||||||
</span>
|
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||||
<span className="tag is-light">
|
{slots.total} slots; {slots.free} free
|
||||||
{result.slots.total}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<a
|
{/* ACTIONS */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<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"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadDCPPResult(
|
download(
|
||||||
searchInstance.id,
|
airDCPPSearchInstance.id,
|
||||||
result.id,
|
id,
|
||||||
result.name,
|
comicObjectId,
|
||||||
result.size,
|
name,
|
||||||
result.type,
|
size,
|
||||||
|
type,
|
||||||
|
{
|
||||||
|
protocol: `ws`,
|
||||||
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<i className="fas fa-file-download"></i>
|
Download
|
||||||
</a>
|
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
),
|
||||||
})}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
<div className="">
|
||||||
<article className="message is-info">
|
<article
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
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>
|
||||||
|
|
||||||
|
<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{" "}
|
Searching via <strong>AirDC++</strong> is still in{" "}
|
||||||
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
||||||
or may not work at all. Searches from <code>ADCS</code> hubs are
|
or may not work at all. Searches from{" "}
|
||||||
more reliable than <code>NMDCS</code> ones.
|
<code className="font-hasklig">ADCS</code> hubs are more
|
||||||
|
reliable than <code className="font-hasklig">NMDCS</code> ones.
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,4 +440,4 @@ export const AcquisitionPanel = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AcquisitionPanel;
|
export default AcquisitionPanel;
|
||||||
|
|||||||
@@ -1,87 +1,26 @@
|
|||||||
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
import React, { ReactElement } from "react";
|
||||||
import React, { ReactElement, useCallback } from "react";
|
import Select from "react-select";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
import Select, { components } from "react-select";
|
|
||||||
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
|
|
||||||
import { refineQuery } from "filename-parser";
|
|
||||||
|
|
||||||
export const Menu = (props): ReactElement => {
|
export const Menu = (props): ReactElement => {
|
||||||
const { data } = props;
|
const {
|
||||||
const { setSlidingPanelContentId, setVisible } = props.handlers;
|
filteredActionOptions,
|
||||||
const dispatch = useDispatch();
|
customStyles,
|
||||||
const openDrawerWithCVMatches = useCallback(() => {
|
handleActionSelection,
|
||||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
Placeholder,
|
||||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
} = props.configuration;
|
||||||
|
|
||||||
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);
|
|
||||||
}, [dispatch, 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
className="basic-single"
|
|
||||||
classNamePrefix="select"
|
|
||||||
components={{ Placeholder }}
|
components={{ Placeholder }}
|
||||||
placeholder={
|
placeholder={
|
||||||
<span>
|
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||||
<i className="fa-solid fa-list"></i> Actions
|
<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>
|
</span>
|
||||||
}
|
}
|
||||||
|
styles={customStyles}
|
||||||
name="actions"
|
name="actions"
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
options={filteredActionOptions}
|
options={filteredActionOptions}
|
||||||
|
|||||||
62
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import React, { ReactElement, useCallback, useState } from "react";
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||||
import Creatable from "react-select/creatable";
|
import Creatable from "react-select/creatable";
|
||||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||||
|
|
||||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
interface AsyncSelectPaginateProps {
|
||||||
|
metronResource: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: object;
|
||||||
|
onChange?(...args: unknown[]): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||||
|
|
||||||
@@ -38,11 +44,4 @@ export const AsyncSelectPaginate = (props): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AsyncSelectPaginate.propTypes = {
|
|
||||||
metronResource: PropTypes.string.isRequired,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
value: PropTypes.object,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AsyncSelectPaginate;
|
export default AsyncSelectPaginate;
|
||||||
|
|||||||
@@ -1,45 +1,70 @@
|
|||||||
import React, { useState, ReactElement, useCallback } from "react";
|
import React, { useState, ReactElement, useCallback, useMemo } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
|
||||||
|
|
||||||
import { RawFileDetails } from "./RawFileDetails";
|
import { RawFileDetails } from "./RawFileDetails";
|
||||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
|
||||||
|
|
||||||
import TabControls from "./TabControls";
|
import TabControls from "./TabControls";
|
||||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
|
||||||
import { Menu } from "./ActionMenu/Menu";
|
import { Menu } from "./ActionMenu/Menu";
|
||||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
import { components } from "react-select";
|
||||||
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-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 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 { 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 = {};
|
|
||||||
/**
|
/**
|
||||||
* Component for displaying the metadata for a comic in greater detail.
|
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
||||||
|
* for metadata, archive operations, and acquisition.
|
||||||
*
|
*
|
||||||
* @component
|
* @param data.queryClient - react-query client passed through to the CV match
|
||||||
* @example
|
* panel so it can invalidate queries after a match is applied.
|
||||||
* return (
|
* @param data.comicObjectId - optional override for the comic ID; used when the
|
||||||
* <ComicDetail/>
|
* component is rendered outside a route that provides the ID via `useParams`.
|
||||||
* )
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
@@ -47,110 +72,67 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||||
|
acquisition,
|
||||||
|
createdAt,
|
||||||
},
|
},
|
||||||
userSettings,
|
userSettings,
|
||||||
|
queryClient,
|
||||||
|
comicObjectId: comicObjectIdProp,
|
||||||
} = data;
|
} = data;
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
|
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
const [modalIsOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const 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 { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
// Action event handlers
|
||||||
|
const openDrawerWithCVMatches = () => {
|
||||||
const openModal = useCallback((filePath) => {
|
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||||
setIsOpen(true);
|
setSlidingPanelContentId("CVMatches");
|
||||||
dispatch(
|
setVisible(true);
|
||||||
extractComicArchive(filePath, {
|
|
||||||
type: "full",
|
|
||||||
purpose: "reading",
|
|
||||||
imageResizeOptions: {
|
|
||||||
baseWidth: 1024,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const afterOpenModal = useCallback((things) => {
|
|
||||||
// references are now sync'd and can be accessed.
|
|
||||||
// subtitle.style.color = "#f00";
|
|
||||||
console.log("kolaveri", things);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// sliding panel init
|
|
||||||
const contentForSlidingPanel = {
|
|
||||||
CVMatches: {
|
|
||||||
content: (props) => (
|
|
||||||
<>
|
|
||||||
<div 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 the availability of CV metadata
|
const openEditMetadataPanel = useCallback(() => {
|
||||||
const isComicBookMetadataAvailable =
|
setSlidingPanelContentId("editComicBookMetadata");
|
||||||
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hide "match on Comic Vine" when there are no raw file details — matching
|
||||||
|
// requires file metadata to seed the search query.
|
||||||
|
const Placeholder = components.Placeholder;
|
||||||
|
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||||
|
if (isUndefined(rawFileDetails)) {
|
||||||
|
return item.value !== "match-on-comic-vine";
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleActionSelection = (action: ActionOption) => {
|
||||||
|
switch (action.value) {
|
||||||
|
case "match-on-comic-vine":
|
||||||
|
openDrawerWithCVMatches();
|
||||||
|
break;
|
||||||
|
case "edit-metdata":
|
||||||
|
openEditMetadataPanel();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for metadata availability
|
||||||
|
const isComicBookMetadataAvailable =
|
||||||
|
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
||||||
|
|
||||||
|
const hasAnyMetadata =
|
||||||
|
isComicBookMetadataAvailable ||
|
||||||
|
!isEmpty(comicInfo) ||
|
||||||
|
!isNil(locg);
|
||||||
|
|
||||||
// check for the availability of rawFileDetails
|
|
||||||
const areRawFileDetailsAvailable =
|
const areRawFileDetailsAvailable =
|
||||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||||
|
|
||||||
const { issueName, url } = determineCoverFile({
|
const { issueName, url } = determineCoverFile({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
@@ -158,158 +140,114 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
locg,
|
locg,
|
||||||
});
|
});
|
||||||
|
|
||||||
// query for airdc++
|
// Query for airdc++
|
||||||
const airDCPPQuery = {
|
const airDCPPQuery = useMemo(() => ({
|
||||||
issue: {
|
issue: { name: issueName },
|
||||||
name: issueName,
|
}), [issueName]);
|
||||||
},
|
|
||||||
|
// Create tab configuration
|
||||||
|
const openReconcilePanel = useCallback(() => {
|
||||||
|
setSlidingPanelContentId("metadataReconciliation");
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabGroup = useMemo(() => createTabConfig({
|
||||||
|
data: data.data,
|
||||||
|
hasAnyMetadata,
|
||||||
|
areRawFileDetailsAvailable,
|
||||||
|
airDCPPQuery,
|
||||||
|
comicObjectId: _id,
|
||||||
|
userSettings,
|
||||||
|
issueName,
|
||||||
|
acquisition,
|
||||||
|
onReconcileMetadata: openReconcilePanel,
|
||||||
|
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
|
||||||
|
|
||||||
|
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
|
||||||
|
|
||||||
|
// Sliding panel content mapping
|
||||||
|
const renderSlidingPanelContent = () => {
|
||||||
|
switch (slidingPanelContentId) {
|
||||||
|
case "CVMatches":
|
||||||
|
return (
|
||||||
|
<CVMatchesPanel
|
||||||
|
rawFileDetails={rawFileDetails}
|
||||||
|
inferredMetadata={inferredMetadata}
|
||||||
|
comicVineMatches={comicVineMatches}
|
||||||
|
// Prefer the route param; fall back to the data ID when rendered outside a route.
|
||||||
|
comicObjectId={comicObjectId || _id}
|
||||||
|
queryClient={queryClient}
|
||||||
|
onMatchApplied={() => {
|
||||||
|
setVisible(false);
|
||||||
|
setActiveTab(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "editComicBookMetadata":
|
||||||
|
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tab content and header details
|
|
||||||
const tabGroup = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Volume Information",
|
|
||||||
icon: <i className="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-floppy-disk"></i>,
|
|
||||||
name: "Acquisition",
|
|
||||||
content: (
|
|
||||||
<AcquisitionPanel
|
|
||||||
query={airDCPPQuery}
|
|
||||||
comicObjectId={_id}
|
|
||||||
comicObject={data.data}
|
|
||||||
userSettings={userSettings}
|
|
||||||
key={4}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
shouldShow: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
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 (
|
return (
|
||||||
<section className="container">
|
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<div className="section">
|
<div className="section">
|
||||||
{!isNil(data) && !isEmpty(data) && (
|
{!isNil(data) && !isEmpty(data) && (
|
||||||
<>
|
<>
|
||||||
<h1 className="title">{issueName}</h1>
|
<div>
|
||||||
<div className="columns is-multiline">
|
<div className="flex flex-row mt-5">
|
||||||
<div className="column is-narrow">
|
|
||||||
<Card
|
<Card
|
||||||
imageUrl={url}
|
imageUrl={url}
|
||||||
orientation={"vertical"}
|
orientation={"cover-only"}
|
||||||
hasDetails={false}
|
hasDetails={false}
|
||||||
cardContainerStyle={{ maxWidth: 275 }}
|
|
||||||
/>
|
/>
|
||||||
{/* action dropdown */}
|
|
||||||
<div className="mt-4 is-size-7">
|
{/* raw file details */}
|
||||||
<Menu
|
|
||||||
data={data.data}
|
|
||||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* raw file details */}
|
|
||||||
<div className="column">
|
|
||||||
{!isUndefined(rawFileDetails) &&
|
{!isUndefined(rawFileDetails) &&
|
||||||
!isEmpty(rawFileDetails.cover) && (
|
!isEmpty(rawFileDetails?.cover) && (
|
||||||
<>
|
<div className="grid">
|
||||||
<RawFileDetails
|
<RawFileDetails
|
||||||
data={{
|
data={{
|
||||||
rawFileDetails: rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata: inferredMetadata,
|
inferredMetadata,
|
||||||
|
createdAt,
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
{/* Read comic button */}
|
|
||||||
<button
|
|
||||||
className="button is-success is-light"
|
|
||||||
onClick={() => openModal(rawFileDetails.filePath)}
|
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-book-open mr-2"></i>
|
{/* action dropdown */}
|
||||||
Read
|
<div className="mt-1 flex flex-row gap-2 w-full">
|
||||||
</button>
|
<Menu
|
||||||
|
data={data.data}
|
||||||
<Modal
|
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||||
style={{ content: { marginTop: "2rem" } }}
|
configuration={{
|
||||||
isOpen={modalIsOpen}
|
filteredActionOptions,
|
||||||
onAfterOpen={afterOpenModal}
|
customStyles,
|
||||||
onRequestClose={closeModal}
|
handleActionSelection,
|
||||||
contentLabel="Example Modal"
|
Placeholder,
|
||||||
>
|
}}
|
||||||
<button onClick={closeModal}>close</button>
|
|
||||||
{extractedComicBook && (
|
|
||||||
<ComicViewer
|
|
||||||
pages={extractedComicBook}
|
|
||||||
direction="ltr"
|
|
||||||
className={{closeButton: "border: 1px solid red;"}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</Modal>
|
</RawFileDetails>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{<TabControls filteredTabs={filteredTabs} />}
|
<TabControls
|
||||||
|
filteredTabs={filteredTabs}
|
||||||
|
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
/>
|
||||||
|
|
||||||
<SlidingPane
|
<StyledSlidingPanel
|
||||||
isOpen={visible}
|
isOpen={visible}
|
||||||
onRequestClose={() => setVisible(false)}
|
onRequestClose={() => setVisible(false)}
|
||||||
title={"Comic Vine Search Matches"}
|
title={"Comic Vine Search Matches"}
|
||||||
width={"600px"}
|
width={"600px"}
|
||||||
>
|
>
|
||||||
{slidingPanelContentId !== "" &&
|
{renderSlidingPanelContent()}
|
||||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
</StyledSlidingPanel>
|
||||||
</SlidingPane>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,4 +255,4 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ComicDetail;
|
export default ComicDetail;
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import React, { ReactElement } from "react";
|
||||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
|
|
||||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||||
|
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
|
||||||
|
|
||||||
export const ComicDetailContainer = (): ReactElement | null => {
|
export const ComicDetailContainer = (): ReactElement | null => {
|
||||||
const comicBookDetailData = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
|
||||||
);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
useEffect(() => {
|
const queryClient = useQueryClient();
|
||||||
dispatch(getComicBookDetailById(comicObjectId));
|
|
||||||
// dispatch(getSettings());
|
const {
|
||||||
}, [dispatch]);
|
data: comicBookDetailData,
|
||||||
return !isEmpty(comicBookDetailData) ? (
|
isLoading,
|
||||||
<ComicDetail data={comicBookDetailData} />
|
isError,
|
||||||
|
} = useGetComicByIdQuery(
|
||||||
|
{ id: comicObjectId! },
|
||||||
|
{ enabled: !!comicObjectId }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>Error loading comic details</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adaptedData = comicBookDetailData?.comic
|
||||||
|
? adaptGraphQLComicToLegacy(comicBookDetailData.comic)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return adaptedData ? (
|
||||||
|
<ComicDetail
|
||||||
|
data={adaptedData}
|
||||||
|
queryClient={queryClient}
|
||||||
|
comicObjectId={comicObjectId}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,119 +1,118 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { isUndefined } from "lodash";
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
export const ComicVineDetails = (props): ReactElement => {
|
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 => {
|
||||||
const { data, updatedAt } = props;
|
const { data, updatedAt } = props;
|
||||||
return (
|
return (
|
||||||
<div className="column is-half">
|
<div className="text-slate-500 dark:text-gray-400">
|
||||||
<div className="comic-detail comicvine-metadata">
|
<div className="">
|
||||||
<dl>
|
<div>
|
||||||
<dt>ComicVine Metadata</dt>
|
<div className="flex flex-row gap-4">
|
||||||
<dd className="is-size-7">
|
<div className="min-w-fit">
|
||||||
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
<Card
|
||||||
</dd>
|
imageUrl={data.volumeInformation.image.thumb_url}
|
||||||
|
orientation={"cover-only"}
|
||||||
<dd>
|
hasDetails={false}
|
||||||
<div className="columns mt-2">
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
<div className="column is-2">
|
/>
|
||||||
<Card
|
</div>
|
||||||
imageUrl={data.volumeInformation.image.thumb_url}
|
<div className="flex flex-col gap-5">
|
||||||
orientation={"vertical"}
|
<div className="flex flex-row">
|
||||||
hasDetails={false}
|
<div>
|
||||||
// cardContainerStyle={{ maxWidth: 200 }}
|
{/* Title */}
|
||||||
/>
|
<div>
|
||||||
</div>
|
<div className="text-lg">{data.name}</div>
|
||||||
<div className="column is-10">
|
<div className="text-sm">
|
||||||
<dl>
|
Is a part of{" "}
|
||||||
<dt>
|
<span className="has-text-info">
|
||||||
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
|
{data.volumeInformation.name}
|
||||||
</dt>
|
</span>
|
||||||
<dd>
|
|
||||||
Is a part of{" "}
|
|
||||||
<span className="has-text-info">
|
|
||||||
{data.volumeInformation.name}
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</div>
|
||||||
</dl>
|
|
||||||
|
{/* 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>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{data.issue_number && (
|
||||||
|
<div className="">
|
||||||
|
<span>Issue Number</span>
|
||||||
|
<span>{data.issue_number}</span>
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</div>
|
||||||
</dl>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ComicVineDetails;
|
export default ComicVineDetails;
|
||||||
|
|
||||||
ComicVineDetails.propTypes = {
|
|
||||||
updatedAt: PropTypes.string,
|
|
||||||
data: PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
number: PropTypes.string,
|
|
||||||
resource_type: PropTypes.string,
|
|
||||||
id: PropTypes.number,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,23 +1,53 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
import MatchResult from "./MatchResult";
|
||||||
import MatchResult from "../MatchResult";
|
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
interface ComicVineMatchPanelProps {
|
||||||
const {
|
props: {
|
||||||
comicObjectId,
|
comicObjectId: string;
|
||||||
comicVineSearchQueryObject,
|
comicVineMatches: any[];
|
||||||
comicVineAPICallProgress,
|
queryClient?: any;
|
||||||
comicVineSearchResults,
|
onMatchApplied?: () => void;
|
||||||
} = comicVineData.props;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="search-results-container">
|
<div>
|
||||||
{!isEmpty(comicVineSearchResults) && (
|
{!isEmpty(comicVineMatches) ? (
|
||||||
<MatchResult
|
<MatchResult
|
||||||
matchData={comicVineSearchResults}
|
matchData={comicVineMatches}
|
||||||
comicObjectId={comicObjectId}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
96
src/client/components/ComicDetail/ComicVineSearchForm.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import Collapsible from "react-collapsible";
|
||||||
|
import { fetchComicVineMatches } from "../../actions/fileops.actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for performing search against ComicVine
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* return (
|
||||||
|
* <ComicVineSearchForm data={rawFileDetails} />
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
export const ComicVineSearchForm = (data) => {
|
||||||
|
const onSubmit = useCallback((value) => {
|
||||||
|
const userInititatedQuery = {
|
||||||
|
inferredIssueDetails: {
|
||||||
|
name: value.issueName,
|
||||||
|
number: value.issueNumber,
|
||||||
|
subtitle: "",
|
||||||
|
year: value.issueYear,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
||||||
|
}, []);
|
||||||
|
const validate = () => {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MyForm = () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <MyForm />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineSearchForm;
|
||||||
@@ -1,33 +1,127 @@
|
|||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DownloadProgressTickProps
|
||||||
|
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
|
||||||
|
*/
|
||||||
|
interface DownloadProgressTickProps {
|
||||||
|
bundleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the download tick data received over the socket.
|
||||||
|
*
|
||||||
|
* @typedef DownloadTickData
|
||||||
|
* @property {number} id - Unique download ID
|
||||||
|
* @property {string} name - File name (e.g. "movie.mkv")
|
||||||
|
* @property {number} downloaded_bytes - Bytes downloaded so far
|
||||||
|
* @property {number} size - Total size in bytes
|
||||||
|
* @property {number} speed - Current download speed (bytes/sec)
|
||||||
|
* @property {number} seconds_left - Estimated seconds remaining
|
||||||
|
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
|
||||||
|
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
|
||||||
|
* @property {{ online: number; total: number; str: string }} sources
|
||||||
|
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
|
||||||
|
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
|
||||||
|
*/
|
||||||
|
interface DownloadTickData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
downloaded_bytes: number;
|
||||||
|
size: number;
|
||||||
|
speed: number;
|
||||||
|
seconds_left: number;
|
||||||
|
status: {
|
||||||
|
id: string;
|
||||||
|
str: string;
|
||||||
|
completed: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
hook_error: any;
|
||||||
|
};
|
||||||
|
sources: {
|
||||||
|
online: number;
|
||||||
|
total: number;
|
||||||
|
str: string;
|
||||||
|
};
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||||
|
bundleId,
|
||||||
|
}): ReactElement | null => {
|
||||||
|
const socketRef = useRef<Socket>();
|
||||||
|
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = useStore.getState().getSocket("manual");
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.emit("call", "socket.listenFileProgress", {
|
||||||
|
namespace: "/manual",
|
||||||
|
config: {
|
||||||
|
protocol: `ws`,
|
||||||
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for each "downloadTick" event.
|
||||||
|
* Only update state if event.id matches bundleId.
|
||||||
|
*
|
||||||
|
* @param {DownloadTickData} data - Payload from the server
|
||||||
|
*/
|
||||||
|
const onDownloadTick = (data: DownloadTickData) => {
|
||||||
|
// Compare numeric data.id to string bundleId
|
||||||
|
if (data.id === parseInt(bundleId, 10)) {
|
||||||
|
setTick(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("downloadTick", onDownloadTick);
|
||||||
|
return () => {
|
||||||
|
socket.off("downloadTick", onDownloadTick);
|
||||||
|
};
|
||||||
|
}, [socketRef, bundleId]);
|
||||||
|
|
||||||
|
if (!tick) {
|
||||||
|
return <>Nothing detected.</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute human-readable values and percentages
|
||||||
|
const downloaded = prettyBytes(tick.downloaded_bytes);
|
||||||
|
const total = prettyBytes(tick.size);
|
||||||
|
const percent = tick.size > 0
|
||||||
|
? Math.round((tick.downloaded_bytes / tick.size) * 100)
|
||||||
|
: 0;
|
||||||
|
const speed = prettyBytes(tick.speed) + "/s";
|
||||||
|
const minutesLeft = Math.round(tick.seconds_left / 60);
|
||||||
|
|
||||||
export const DownloadProgressTick = (props): ReactElement => {
|
|
||||||
return (
|
return (
|
||||||
<div >
|
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||||
<h4 className="is-size-6">{props.data.name}</h4>
|
{/* Downloaded vs Total */}
|
||||||
<div>
|
<div className="mt-1 flex items-center space-x-2">
|
||||||
<span className="is-size-3 has-text-weight-semibold">
|
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||||
{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>
|
||||||
<div className="is-size-5">
|
|
||||||
{prettyBytes(props.data.speed)} per second.
|
{/* 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>
|
||||||
<div className="is-size-5">
|
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
|
||||||
Time left:
|
|
||||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
{/* 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>
|
||||||
<div>{props.data.target}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,108 +1,154 @@
|
|||||||
import React, { useEffect, useContext, ReactElement } from "react";
|
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 {
|
import {
|
||||||
getBundlesForComic,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
} from "../../actions/airdcpp.actions";
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
TORRENT_JOB_SERVICE_BASE_URI,
|
||||||
import { RootState } from "threetwo-ui-typings";
|
} from "../../constants/endpoints";
|
||||||
import { isEmpty, isNil, map } from "lodash";
|
import { useStore } from "../../store";
|
||||||
import prettyBytes from "pretty-bytes";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import dayjs from "dayjs";
|
import { useParams } from "react-router-dom";
|
||||||
import ellipsize from "ellipsize";
|
|
||||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
|
||||||
|
|
||||||
interface IDownloadsPanelProps {
|
export interface TorrentDetails {
|
||||||
data: any;
|
infoHash: string;
|
||||||
comicObjectId: string;
|
progress: number;
|
||||||
|
downloadSpeed?: number;
|
||||||
|
uploadSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadsPanel = (
|
/**
|
||||||
props: IDownloadsPanelProps,
|
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||||
): ReactElement | null => {
|
* - DC++ (AirDCPP) bundles
|
||||||
const bundles = useSelector((state: RootState) => {
|
* - Torrent downloads
|
||||||
return state.airdcpp.bundles;
|
* 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",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { socketIOInstance } = useStore(
|
||||||
|
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers socket listeners on mount and cleans up on unmount.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socketIOInstance) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for incoming torrent data events.
|
||||||
|
* Merges new entries or updates existing ones by infoHash.
|
||||||
|
*
|
||||||
|
* @param {TorrentDetails} data - Payload from the socket event.
|
||||||
|
*/
|
||||||
|
const handleTorrentData = (data: TorrentDetails) => {
|
||||||
|
setTorrentDetails((prev) => {
|
||||||
|
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
|
||||||
|
if (idx === -1) {
|
||||||
|
return [...prev, data];
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], ...data };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
|
||||||
|
};
|
||||||
|
}, [socketIOInstance]);
|
||||||
|
|
||||||
|
// ————— DC++ Bundles (via REST) —————
|
||||||
|
const { data: bundles } = useQuery({
|
||||||
|
queryKey: ["bundles", comicObjectId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
comicObjectId,
|
||||||
|
config: {
|
||||||
|
protocol: `ws`,
|
||||||
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// AirDCPP Socket initialization
|
// ————— Torrent Jobs (via REST) —————
|
||||||
const userSettings = useSelector((state: RootState) => state.settings.data);
|
const { data: rawJobs = [] } = useQuery<any[]>({
|
||||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
|
||||||
airDCPPState: { socket, settings },
|
|
||||||
} = airDCPPConfiguration;
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
if (activeTab !== "torrents") return;
|
||||||
if (!isEmpty(userSettings)) {
|
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
|
||||||
dispatch(
|
}, [activeTab]);
|
||||||
getBundlesForComic(props.comicObjectId, socket, {
|
|
||||||
username: `${settings.directConnect.client.host.username}`,
|
|
||||||
password: `${settings.directConnect.client.host.password}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
}, [dispatch, airDCPPConfiguration]);
|
|
||||||
|
|
||||||
const Bundles = (props) => {
|
return (
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return !isNil(props.data) ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="columns is-multiline">
|
<div className="mt-5 mb-3">
|
||||||
{!isEmpty(socket) ? (
|
<nav className="flex space-x-2">
|
||||||
<Bundles data={bundles} />
|
<button
|
||||||
) : (
|
onClick={() => setActiveTab("directconnect")}
|
||||||
<div className="column is-three-fifths">
|
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
<article className="message is-info">
|
activeTab === "directconnect"
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
? "bg-green-500 text-white"
|
||||||
AirDC++ is not configured. Please configure it in{" "}
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
<code>Settings</code>.
|
}`}
|
||||||
</div>
|
>
|
||||||
</article>
|
DC++
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null;
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DownloadsPanel;
|
export default DownloadsPanel;
|
||||||
|
|||||||
@@ -1,55 +1,41 @@
|
|||||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { Form, Field, FieldRenderProps } from "react-final-form";
|
||||||
import { Form, Field } from "react-final-form";
|
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
|
||||||
export const EditMetadataPanel = (props): ReactElement => {
|
interface EditMetadataPanelProps {
|
||||||
const validate = async () => {};
|
data: {
|
||||||
|
name?: string | null;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
|
||||||
|
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||||
|
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
|
||||||
|
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||||
|
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Sliding panel form for manually editing comic metadata fields. */
|
||||||
|
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
|
||||||
const onSubmit = async () => {};
|
const 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,
|
|
||||||
);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
validate={validate}
|
mutators={{ ...arrayMutators }}
|
||||||
mutators={{
|
|
||||||
...arrayMutators,
|
|
||||||
}}
|
|
||||||
render={({
|
render={({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
form: {
|
form: {
|
||||||
mutators: { push, pop },
|
mutators: { push, pop },
|
||||||
}, // injected from final-form-arrays above
|
},
|
||||||
pristine,
|
|
||||||
form,
|
|
||||||
submitting,
|
|
||||||
values,
|
|
||||||
}) => (
|
}) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Issue Name */}
|
{/* Issue Name */}
|
||||||
@@ -58,93 +44,59 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
<label className="label">Issue Details</label>
|
<label className="label">Issue Details</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-body">
|
<div className="field-body">
|
||||||
<div className="field">
|
<Field
|
||||||
<p className="control is-expanded has-icons-left">
|
name="issue_name"
|
||||||
<Field
|
component="input"
|
||||||
name="issue_name"
|
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"
|
||||||
component="input"
|
initialValue={data.name}
|
||||||
className="input"
|
placeholder={"Issue Name"}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
{/* Issue Number and year */}
|
{/* Issue Number and year */}
|
||||||
<div className="field is-horizontal">
|
<div className="mt-4 flex flex-row gap-2">
|
||||||
<div className="field-label"></div>
|
<div>
|
||||||
<div className="field-body">
|
<div className="text-sm">Issue Number</div>
|
||||||
<div className="field">
|
<Field
|
||||||
<p className="control has-icons-left">
|
name="issue_number"
|
||||||
<Field
|
component="input"
|
||||||
name="issue_number"
|
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"
|
||||||
component="input"
|
placeholder="Issue Number"
|
||||||
className="input"
|
/>
|
||||||
placeholder="Issue Number"
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
/>
|
</div>
|
||||||
<span className="icon is-small is-left">
|
<div>
|
||||||
<i className="fa-solid fa-hashtag"></i>
|
<div className="text-sm">Issue Year</div>
|
||||||
</span>
|
<Field
|
||||||
</p>
|
name="issue_year"
|
||||||
<p className="help">Do not enter the first zero</p>
|
component="input"
|
||||||
</div>
|
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"
|
||||||
{/* year */}
|
/>
|
||||||
<div className="field">
|
|
||||||
<p className="control">
|
|
||||||
<Field
|
|
||||||
name="issue_year"
|
|
||||||
component="input"
|
|
||||||
className="input"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* page count */}
|
<div>
|
||||||
<div className="field is-horizontal">
|
<div className="text-sm">Page Count</div>
|
||||||
<div className="field-label"></div>
|
<Field
|
||||||
<div className="field-body">
|
name="page_count"
|
||||||
<div className="field">
|
component="input"
|
||||||
<p className="control has-icons-left">
|
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"
|
||||||
<Field
|
placeholder="Page Count"
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="field is-horizontal">
|
<div className="mt-2">
|
||||||
<div className="field-label is-normal">
|
<label className="text-sm">Description</label>
|
||||||
<label className="label">Description</label>
|
<Field
|
||||||
</div>
|
name={"description"}
|
||||||
<div className="field-body">
|
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"
|
||||||
<div className="field">
|
component={TextareaAutosizeAdapter}
|
||||||
<p className="control is-expanded has-icons-left">
|
placeholder={"Description"}
|
||||||
<Field
|
/>
|
||||||
name={"description"}
|
|
||||||
className="textarea"
|
|
||||||
component={TextareaAutosizeAdapter}
|
|
||||||
placeholder={"Description"}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-label">
|
<div className="field-label">
|
||||||
@@ -184,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
{/* Publisher */}
|
{/* Publisher */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -255,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
{/* team credits */}
|
{/* team credits */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -333,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
159
src/client/components/ComicDetail/MatchResult.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { isNil, map } from "lodash";
|
||||||
|
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) => {
|
||||||
|
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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)) {
|
||||||
|
issueDescription = convert(match.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<img
|
||||||
|
className="rounded-md"
|
||||||
|
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>
|
||||||
|
<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">
|
||||||
|
{ellipsize(issueDescription, 300)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 my-4 ml-10">
|
||||||
|
<div className="">
|
||||||
|
<img
|
||||||
|
src={match.volumeInformation.results.image.icon_url}
|
||||||
|
className="rounded-md w-full"
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchResult;
|
||||||
@@ -1,97 +1,116 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, ReactNode } from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { isUndefined } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
|
import {
|
||||||
|
RawFileDetails as RawFileDetailsType,
|
||||||
|
InferredMetadata,
|
||||||
|
} from "../../graphql/generated";
|
||||||
|
|
||||||
export const RawFileDetails = (props): ReactElement => {
|
type RawFileDetailsProps = {
|
||||||
const { rawFileDetails, inferredMetadata } = props.data;
|
data?: {
|
||||||
|
rawFileDetails?: RawFileDetailsType;
|
||||||
|
inferredMetadata?: InferredMetadata;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
||||||
|
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||||
|
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="comic-detail raw-file-details column is-three-fifths">
|
<div className="max-w-2xl ml-5">
|
||||||
<dl>
|
<div className="px-4 sm:px-6">
|
||||||
<dt>Raw File Details</dt>
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
<dd className="is-size-7">
|
<span className="text-xl">{rawFileDetails?.name}</span>
|
||||||
{rawFileDetails.containedIn +
|
</p>
|
||||||
"/" +
|
</div>
|
||||||
rawFileDetails.name +
|
<div className="px-4 py-5 sm:px-6">
|
||||||
rawFileDetails.extension}
|
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||||
</dd>
|
<div className="sm:col-span-1">
|
||||||
<dd>
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<div className="field is-grouped mt-2">
|
Raw File Details
|
||||||
<div className="control">
|
</dt>
|
||||||
<div className="tags has-addons">
|
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||||
<span className="tag">Size</span>
|
{rawFileDetails?.containedIn}
|
||||||
<span className="tag is-info is-light">
|
{"/"}
|
||||||
{prettyBytes(rawFileDetails.fileSize)}
|
{rawFileDetails?.name}
|
||||||
</span>
|
{rawFileDetails?.extension}
|
||||||
</div>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="control">
|
<div className="sm:col-span-1">
|
||||||
<div className="tags has-addons">
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<span className="tag">Extension</span>
|
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) ? (
|
||||||
<span className="tag is-primary is-light">
|
<span className="tag is-primary is-light">
|
||||||
{rawFileDetails.extension}
|
{inferredMetadata?.issue?.number}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
<div className="sm:col-span-1">
|
||||||
</dl>
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
MIMEType
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-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">
|
||||||
|
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
<dl>
|
{rawFileDetails?.mimeType}
|
||||||
{/* inferred metadata */}
|
|
||||||
<dt>Inferred Issue Metadata</dt>
|
|
||||||
<dd>
|
|
||||||
<div className="field is-grouped mt-2">
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Name</span>
|
|
||||||
<span className="tag is-info is-light">
|
|
||||||
{inferredMetadata.issue.name}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</dd>
|
||||||
{!isUndefined(inferredMetadata.issue.number) ? (
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Number</span>
|
|
||||||
<span className="tag is-primary is-light">
|
|
||||||
{inferredMetadata.issue.number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
<div className="sm:col-span-1">
|
||||||
</dl>
|
<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">
|
||||||
|
{/* 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">
|
||||||
|
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RawFileDetails;
|
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,
|
|
||||||
cover: PropTypes.shape({
|
|
||||||
filePath: PropTypes.string,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
inferredMetadata: PropTypes.shape({
|
|
||||||
issue: PropTypes.shape({
|
|
||||||
year: PropTypes.string,
|
|
||||||
name: PropTypes.string,
|
|
||||||
number: PropTypes.number,
|
|
||||||
subtitle: PropTypes.string,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
65
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||||
|
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||||
|
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||||
|
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
|
||||||
|
|
||||||
|
interface CVMatchesPanelProps {
|
||||||
|
rawFileDetails?: RawFileDetails;
|
||||||
|
inferredMetadata: InferredMetadata;
|
||||||
|
comicVineMatches: any[];
|
||||||
|
comicObjectId: string;
|
||||||
|
queryClient: any;
|
||||||
|
onMatchApplied: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding panel content for ComicVine match search.
|
||||||
|
*
|
||||||
|
* Renders a search form pre-populated from `rawFileDetails`, a preview of the
|
||||||
|
* inferred issue being searched for, and a list of ComicVine match candidates
|
||||||
|
* the user can apply to the comic.
|
||||||
|
*
|
||||||
|
* @param props.onMatchApplied - Called after the user selects and applies a match,
|
||||||
|
* allowing the parent to close the panel and refresh state.
|
||||||
|
*/
|
||||||
|
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
||||||
|
rawFileDetails,
|
||||||
|
inferredMetadata,
|
||||||
|
comicVineMatches,
|
||||||
|
comicObjectId,
|
||||||
|
queryClient,
|
||||||
|
onMatchApplied,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<ComicVineSearchForm data={rawFileDetails} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||||
|
<p className="">Searching for:</p>
|
||||||
|
{inferredMetadata.issue ? (
|
||||||
|
<>
|
||||||
|
<span className="">{inferredMetadata.issue?.name} </span>
|
||||||
|
<span className=""> # {inferredMetadata.issue?.number} </span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ComicVineMatchPanel
|
||||||
|
props={{
|
||||||
|
comicVineMatches,
|
||||||
|
comicObjectId,
|
||||||
|
queryClient,
|
||||||
|
onMatchApplied,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
type EditMetadataPanelWrapperProps = {
|
||||||
|
rawFileDetails?: RawFileDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
||||||
|
rawFileDetails,
|
||||||
|
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;
|
||||||
@@ -1,50 +1,59 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from "react";
|
import React, { ReactElement, Suspense, useState } from "react";
|
||||||
import { isEmpty, isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
|
|
||||||
export const TabControls = (props): ReactElement => {
|
export const TabControls = (props): ReactElement => {
|
||||||
const comicBookDetailData = useSelector(
|
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
||||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
|
||||||
);
|
|
||||||
const { filteredTabs } = props;
|
|
||||||
|
|
||||||
const [active, setActive] = useState(filteredTabs[0].id);
|
const [active, setActive] = useState(filteredTabs[0].id);
|
||||||
useEffect(() => {
|
|
||||||
setActive(filteredTabs[0].id);
|
// Use controlled state if provided, otherwise use internal state
|
||||||
}, [comicBookDetailData]);
|
const currentActive = activeTab !== undefined ? activeTab : active;
|
||||||
|
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="tabs">
|
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||||
<ul>
|
<div className="border-b border-gray-200">
|
||||||
{filteredTabs.map(({ id, name, icon }) => (
|
<nav className="flex gap-6" aria-label="Tabs">
|
||||||
<li
|
{filteredTabs.map(({ id, name, icon }) => (
|
||||||
key={id}
|
<a
|
||||||
className={id === active ? "is-active" : ""}
|
key={id}
|
||||||
onClick={() => setActive(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
|
||||||
{/* Downloads tab and count badge */}
|
? "border-b border-cyan-50 dark:text-slate-200"
|
||||||
<a>
|
: "border-b border-transparent"
|
||||||
{id === 5 &&
|
}`}
|
||||||
!isNil(comicBookDetailData.acquisition.directconnect) ? (
|
aria-current="page"
|
||||||
<span className="download-icon-labels">
|
onClick={() => handleSetActive(id)}
|
||||||
<i className="fa-solid fa-download"></i>
|
>
|
||||||
<span className="tag downloads-count is-info is-light">
|
{/* Downloads tab and count badge */}
|
||||||
{comicBookDetailData.acquisition.directconnect.downloads.length}
|
<>
|
||||||
|
{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" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
) : (
|
<span className="w-5 h-5">{icon}</span>
|
||||||
<span className="icon is-small">{icon}</span>
|
)}
|
||||||
)}
|
{name}
|
||||||
{name}
|
</>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
))}
|
||||||
))}
|
</nav>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{filteredTabs.map(({ id, content }) => {
|
<Suspense fallback={null}>
|
||||||
return active === id ? content : null;
|
{filteredTabs.map(({ id, content }) => (
|
||||||
})}
|
<React.Fragment key={id}>
|
||||||
|
{currentActive === id ? content : null}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,59 +1,157 @@
|
|||||||
import React, { ReactElement, useCallback, useState } from "react";
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { DnD } from "../../shared/Draggable/DnD";
|
||||||
import { DnD } from "../../DnD";
|
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import Sticky from "react-stickynode";
|
|
||||||
import SlidingPane from "react-sliding-pane";
|
import SlidingPane from "react-sliding-pane";
|
||||||
import { extractComicArchive } from "../../../actions/fileops.actions";
|
|
||||||
import { analyzeImage } from "../../../actions/fileops.actions";
|
|
||||||
import { Canvas } from "../../shared/Canvas";
|
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): ReactElement => {
|
export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
||||||
const { data } = props;
|
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 dispatch = useDispatch();
|
|
||||||
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
|
// sliding panel config
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
// current image
|
// current image
|
||||||
const [currentImage, setCurrentImage] = useState([]);
|
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]);
|
||||||
|
|
||||||
// sliding panel init
|
// sliding panel init
|
||||||
const contentForSlidingPanel = {
|
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||||
imageAnalysis: {
|
imageAnalysis: {
|
||||||
content: () => {
|
content: () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<pre className="is-size-7">{currentImage}</pre>
|
<pre className="text-sm">{currentImage}</pre>
|
||||||
{!isEmpty(imageAnalysisResult) ? (
|
{!isEmpty(imageAnalysisResult) ? (
|
||||||
<pre className="is-size-7 p-2 mt-3">
|
<pre className="p-2 mt-3">
|
||||||
<Canvas data={imageAnalysisResult} />
|
<Canvas data={imageAnalysisResult} />
|
||||||
</pre>
|
</pre>
|
||||||
) : null}
|
) : null}
|
||||||
<pre className="is-size-7 mt-3">
|
<pre className="font-hasklig mt-3 text-sm">
|
||||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,56 +160,83 @@ export const ArchiveOperations = (props): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// sliding panel handlers
|
// sliding panel handlers
|
||||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||||
setSlidingPanelContentId("imageAnalysis");
|
setSlidingPanelContentId("imageAnalysis");
|
||||||
dispatch(analyzeImage(imageFilePath));
|
analyzeImage(imageFilePath);
|
||||||
setCurrentImage(imageFilePath);
|
setCurrentImage(imageFilePath);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={2}>
|
<div key={2}>
|
||||||
<button
|
<article
|
||||||
className={
|
role="alert"
|
||||||
isComicBookExtractionInProgress
|
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 is-loading is-warning"
|
|
||||||
: "button is-warning"
|
|
||||||
}
|
|
||||||
onClick={unpackComicArchive}
|
|
||||||
>
|
>
|
||||||
<span className="icon is-small">
|
<div>
|
||||||
<i className="fa-solid fa-box-open"></i>
|
<p>You can perform several operations on your comic book archive.</p>
|
||||||
</span>
|
<p>
|
||||||
<span>Unpack comic archive</span>
|
Uncompressing, re-organizing the individual pages, then
|
||||||
</button>
|
re-compressing to a different format, for example.
|
||||||
<div className="columns">
|
</p>
|
||||||
<div className="mt-5">
|
<p>You can also analyze color histograms of pages.</p>
|
||||||
{!isEmpty(extractedComicBookArchive) ? (
|
</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) ? (
|
||||||
<DnD
|
<DnD
|
||||||
data={extractedComicBookArchive}
|
data={uncompressedArchive}
|
||||||
onClickHandler={openImageAnalysisPanel}
|
onClickHandler={openImageAnalysisPanel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<SlidingPane
|
<SlidingPane
|
||||||
isOpen={visible}
|
isOpen={visible}
|
||||||
@@ -126,4 +251,4 @@ export const ArchiveOperations = (props): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ArchiveOperations;
|
export default ArchiveOperations;
|
||||||
|
|||||||
@@ -1,54 +1,67 @@
|
|||||||
import { isUndefined } from "lodash";
|
import { isUndefined } from "lodash";
|
||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
export const ComicInfoXML = (data): ReactElement => {
|
export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
||||||
const { json } = data;
|
const { json } = data;
|
||||||
return (
|
return (
|
||||||
<div className="comicInfo-metadata">
|
<div className="flex w-3/4">
|
||||||
<dl className="has-text-size-7">
|
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
|
||||||
<dd className="has-text-weight-medium">{json.series[0]}</dd>
|
<dt>
|
||||||
<dd className="mt-2 mb-2">
|
<p className="text-lg">{json.series?.[0]}</p>
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
</dt>
|
||||||
<div className="control">
|
<dd className="text-sm">
|
||||||
<span className="tags has-addons">
|
published by{" "}
|
||||||
<span className="tag">Pages</span>
|
<span className="underline">
|
||||||
<span className="tag is-warning is-light">
|
{json.publisher?.[0]}
|
||||||
{json.publisher[0]}
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</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>
|
|
||||||
</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>
|
||||||
<dd>
|
<span className="inline-flex flex-row gap-2">
|
||||||
<span className="is-size-7">{json.notes[0]}</span>
|
{/* 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)}
|
||||||
|
</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]}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dd className="mt-1 mb-1">{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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,165 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useMemo, useState } from "react";
|
||||||
|
import { isEmpty, isNil } from "lodash";
|
||||||
|
import { Drawer } from "vaul";
|
||||||
import ComicVineDetails from "../ComicVineDetails";
|
import ComicVineDetails from "../ComicVineDetails";
|
||||||
import { convert } from "html-to-text";
|
|
||||||
import { isEmpty } from "lodash";
|
|
||||||
|
|
||||||
export const VolumeInformation = (props): ReactElement => {
|
interface ComicVineMetadata {
|
||||||
|
volumeInformation?: Record<string, unknown>;
|
||||||
|
name?: string;
|
||||||
|
number?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourcedMetadata {
|
||||||
|
comicvine?: ComicVineMetadata;
|
||||||
|
locg?: Record<string, unknown>;
|
||||||
|
comicInfo?: unknown;
|
||||||
|
metron?: unknown;
|
||||||
|
gcd?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeInformationData {
|
||||||
|
sourcedMetadata?: SourcedMetadata;
|
||||||
|
inferredMetadata?: { issue?: unknown };
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeInformationProps {
|
||||||
|
data: VolumeInformationData;
|
||||||
|
onReconcile?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
|
||||||
|
const SOURCED_METADATA_KEYS = [
|
||||||
|
"comicvine",
|
||||||
|
"locg",
|
||||||
|
"comicInfo",
|
||||||
|
"metron",
|
||||||
|
"gcd",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
comicvine: "ComicVine",
|
||||||
|
locg: "League of Comic Geeks",
|
||||||
|
comicInfo: "ComicInfo.xml",
|
||||||
|
metron: "Metron",
|
||||||
|
gcd: "Grand Comics Database",
|
||||||
|
inferredMetadata: "Local File",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_ICONS: Record<string, string> = {
|
||||||
|
comicvine: "icon-[solar--database-bold]",
|
||||||
|
locg: "icon-[solar--users-group-rounded-outline]",
|
||||||
|
comicInfo: "icon-[solar--file-text-outline]",
|
||||||
|
metron: "icon-[solar--planet-outline]",
|
||||||
|
gcd: "icon-[solar--book-outline]",
|
||||||
|
inferredMetadata: "icon-[solar--folder-outline]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetadataSourceChips = ({
|
||||||
|
sources,
|
||||||
|
}: {
|
||||||
|
sources: string[];
|
||||||
|
}): ReactElement => {
|
||||||
|
const [isSheetOpen, setSheetOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||||
|
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
||||||
|
{sources.length} metadata sources detected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<span
|
||||||
|
key={source}
|
||||||
|
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
||||||
|
/>
|
||||||
|
{SOURCE_LABELS[source] ?? source}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => setSheetOpen(true)}
|
||||||
|
>
|
||||||
|
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
||||||
|
Reconcile sources
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||||
|
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
|
||||||
|
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
||||||
|
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Reconciliation UI goes here */}
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays volume metadata for a comic.
|
||||||
|
*
|
||||||
|
* - When multiple sources are present, renders a chip bar listing each source
|
||||||
|
* with a "Reconcile sources" action to merge them.
|
||||||
|
* - When exactly one source is present and it is ComicVine, renders the full
|
||||||
|
* ComicVine detail panel directly.
|
||||||
|
*
|
||||||
|
* @param props.data - Comic data containing sourced and inferred metadata.
|
||||||
|
* @param props.onReconcile - Called when the user triggers source reconciliation.
|
||||||
|
*/
|
||||||
|
export const VolumeInformation = (
|
||||||
|
props: VolumeInformationProps,
|
||||||
|
): ReactElement => {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
const createDescriptionMarkup = (html) => {
|
|
||||||
return { __html: html };
|
const presentSources = useMemo(() => {
|
||||||
};
|
const sources = SOURCED_METADATA_KEYS.filter((key) => {
|
||||||
|
const val = (data?.sourcedMetadata ?? {})[key];
|
||||||
|
if (isNil(val) || isEmpty(val)) return false;
|
||||||
|
// locg returns an object even when empty; require at least one non-null value
|
||||||
|
if (key === "locg")
|
||||||
|
return Object.values(val as Record<string, unknown>).some(
|
||||||
|
(v) => !isNil(v) && v !== "",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!isNil(data?.inferredMetadata?.issue) &&
|
||||||
|
!isEmpty(data?.inferredMetadata?.issue)
|
||||||
|
) {
|
||||||
|
sources.push("inferredMetadata");
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}, [data?.sourcedMetadata, data?.inferredMetadata]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={1}>
|
<div key={1}>
|
||||||
<div className="columns is-multiline">
|
{presentSources.length > 1 && (
|
||||||
<ComicVineDetails
|
<MetadataSourceChips sources={presentSources} />
|
||||||
data={data.sourcedMetadata.comicvine}
|
)}
|
||||||
updatedAt={data.updatedAt}
|
{presentSources.length === 1 &&
|
||||||
/>
|
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
||||||
<div className="column is-8">
|
<ComicVineDetails
|
||||||
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
|
data={data.sourcedMetadata.comicvine}
|
||||||
convert(data.sourcedMetadata.comicvine.description, {
|
updatedAt={data.updatedAt}
|
||||||
baseElements: {
|
/>
|
||||||
selectors: ["p"],
|
)}
|
||||||
},
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
76
src/client/components/ComicDetail/TorrentDownloads.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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;
|
||||||
202
src/client/components/ComicDetail/TorrentSearchPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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;
|
||||||
65
src/client/components/ComicDetail/actionMenuConfig.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StylesConfig } from "react-select";
|
||||||
|
|
||||||
|
export interface ActionOption {
|
||||||
|
value: string;
|
||||||
|
label: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CVMatchLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Match on ComicVine</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const editLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Edit Metadata</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Delete Comic</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionOptions: ActionOption[] = [
|
||||||
|
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||||
|
{ value: "edit-metdata", label: editLabel },
|
||||||
|
{ value: "delete-comic", label: deleteLabel },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const customStyles: StylesConfig<ActionOption, false> = {
|
||||||
|
menu: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
placeholder: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
color: "black",
|
||||||
|
}),
|
||||||
|
option: (base: any, { isFocused }: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
singleValue: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
paddingTop: "0.4rem",
|
||||||
|
}),
|
||||||
|
control: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
color: "black",
|
||||||
|
border: "1px solid rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
};
|
||||||
114
src/client/components/ComicDetail/tabConfig.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { lazy } from "react";
|
||||||
|
import { isNil, isEmpty } from "lodash";
|
||||||
|
|
||||||
|
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
|
||||||
|
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
|
||||||
|
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
|
||||||
|
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
|
||||||
|
const DownloadsPanel = lazy(() => import("./DownloadsPanel"));
|
||||||
|
|
||||||
|
interface TabConfig {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon: React.ReactElement;
|
||||||
|
content: React.ReactElement | null;
|
||||||
|
shouldShow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabConfigParams {
|
||||||
|
data: any;
|
||||||
|
hasAnyMetadata: boolean;
|
||||||
|
areRawFileDetailsAvailable: boolean;
|
||||||
|
airDCPPQuery: any;
|
||||||
|
comicObjectId: string;
|
||||||
|
userSettings: any;
|
||||||
|
issueName: string;
|
||||||
|
acquisition?: any;
|
||||||
|
onReconcileMetadata?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTabConfig = ({
|
||||||
|
data,
|
||||||
|
hasAnyMetadata,
|
||||||
|
areRawFileDetailsAvailable,
|
||||||
|
airDCPPQuery,
|
||||||
|
comicObjectId,
|
||||||
|
userSettings,
|
||||||
|
issueName,
|
||||||
|
acquisition,
|
||||||
|
onReconcileMetadata,
|
||||||
|
}: TabConfigParams): TabConfig[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Volume Information",
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||||
|
),
|
||||||
|
content: hasAnyMetadata ? (
|
||||||
|
<VolumeInformation data={data} onReconcile={onReconcileMetadata} />
|
||||||
|
) : null,
|
||||||
|
shouldShow: hasAnyMetadata,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
|
name: "Archive Operations",
|
||||||
|
content: <ArchiveOperations data={data} />,
|
||||||
|
shouldShow: areRawFileDetailsAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
|
name: "DC++ Search",
|
||||||
|
content: (
|
||||||
|
<AcquisitionPanel
|
||||||
|
query={airDCPPQuery}
|
||||||
|
comicObjectId={comicObjectId}
|
||||||
|
comicObject={data}
|
||||||
|
settings={userSettings}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
icon: (
|
||||||
|
<span className="inline-flex flex-row">
|
||||||
|
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
name: "Torrent Search",
|
||||||
|
content: <TorrentSearchPanel comicObjectId={comicObjectId} issueName={issueName} />,
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Downloads",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
{(acquisition?.directconnect?.downloads?.length || 0) +
|
||||||
|
(acquisition?.torrent?.length || 0)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
content:
|
||||||
|
!isNil(data) && !isEmpty(data) ? (
|
||||||
|
<DownloadsPanel />
|
||||||
|
) : (
|
||||||
|
<div className="column is-three-fifths">
|
||||||
|
<article className="message is-info">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
AirDC++ is not configured. Please configure it in{" "}
|
||||||
|
<code>Settings</code>.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
89
src/client/components/ComicDetail/useComicVineMatching.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { isNil, isUndefined, isEmpty } from "lodash";
|
||||||
|
import { refineQuery } from "filename-parser";
|
||||||
|
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||||
|
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||||
|
|
||||||
|
type ComicVineMatch = {
|
||||||
|
score: number;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComicVineSearchQuery = {
|
||||||
|
inferredIssueDetails: {
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComicVineMetadata = {
|
||||||
|
name?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useComicVineMatching = () => {
|
||||||
|
const [comicVineMatches, setComicVineMatches] = useState<ComicVineMatch[]>([]);
|
||||||
|
|
||||||
|
const fetchComicVineMatches = async (
|
||||||
|
searchPayload: any,
|
||||||
|
issueSearchQuery: ComicVineSearchQuery,
|
||||||
|
seriesSearchQuery: ComicVineSearchQuery,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await axios({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
format: "json",
|
||||||
|
// hack
|
||||||
|
query: issueSearchQuery.inferredIssueDetails.name
|
||||||
|
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||||
|
.trim(),
|
||||||
|
limit: "100",
|
||||||
|
page: 1,
|
||||||
|
resources: "volume",
|
||||||
|
scorerConfiguration: {
|
||||||
|
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||||
|
},
|
||||||
|
rawFileDetails: searchPayload,
|
||||||
|
},
|
||||||
|
transformResponse: (r) => {
|
||||||
|
const matches = JSON.parse(r);
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let matches: ComicVineMatch[] = [];
|
||||||
|
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||||
|
matches = response.data.results;
|
||||||
|
} else {
|
||||||
|
matches = response.data.map((match: ComicVineMatch) => match);
|
||||||
|
}
|
||||||
|
const scoredMatches = matches.sort((a: ComicVineMatch, b: ComicVineMatch) => b.score - a.score);
|
||||||
|
setComicVineMatches(scoredMatches);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handling could be added here if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareAndFetchMatches = (
|
||||||
|
rawFileDetails: RawFileDetailsType | undefined,
|
||||||
|
comicvine: ComicVineMetadata | undefined,
|
||||||
|
) => {
|
||||||
|
let seriesSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
||||||
|
let issueSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
||||||
|
|
||||||
|
if (!isUndefined(rawFileDetails) && rawFileDetails.name) {
|
||||||
|
issueSearchQuery = refineQuery(rawFileDetails.name) as ComicVineSearchQuery;
|
||||||
|
} else if (!isEmpty(comicvine) && comicvine?.name) {
|
||||||
|
issueSearchQuery = refineQuery(comicvine.name) as ComicVineSearchQuery;
|
||||||
|
}
|
||||||
|
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
comicVineMatches,
|
||||||
|
prepareAndFetchMatches,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { Form, Field } from "react-final-form";
|
|
||||||
import Collapsible from "react-collapsible";
|
|
||||||
import { fetchComicVineMatches } from "../actions/fileops.actions";
|
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for performing search against ComicVine
|
|
||||||
*
|
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* return (
|
|
||||||
* <ComicVineSearchForm data={rawFileDetails} />
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
export const ComicVineSearchForm = (data) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const onSubmit = useCallback((value) => {
|
|
||||||
const userInititatedQuery = {
|
|
||||||
inferredIssueDetails: {
|
|
||||||
name: value.issueName,
|
|
||||||
number: value.issueNumber,
|
|
||||||
subtitle: "",
|
|
||||||
year: value.issueYear,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
|
||||||
}, []);
|
|
||||||
const validate = () => {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyForm = () => (
|
|
||||||
<Form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validate={validate}
|
|
||||||
render={({ handleSubmit }) => (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<span className="field is-normal">
|
|
||||||
<label className="label mb-2 is-size-5">Search Manually</label>
|
|
||||||
</span>
|
|
||||||
<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="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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <MyForm />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ComicVineSearchForm;
|
|
||||||
@@ -1,97 +1,73 @@
|
|||||||
import React, { ReactElement, useEffect } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import ZeroState from "./ZeroState";
|
|
||||||
import { RecentlyImported } from "./RecentlyImported";
|
import { RecentlyImported } from "./RecentlyImported";
|
||||||
import { WantedComicsList } from "./WantedComicsList";
|
import { WantedComicsList } from "./WantedComicsList";
|
||||||
import { VolumeGroups } from "./VolumeGroups";
|
import { VolumeGroups } from "./VolumeGroups";
|
||||||
import { LibraryStatistics } from "./LibraryStatistics";
|
import { LibraryStatistics } from "./LibraryStatistics";
|
||||||
import { PullList } from "./PullList";
|
import { PullList } from "./PullList";
|
||||||
import {
|
import {
|
||||||
fetchVolumeGroups,
|
useGetRecentComicsQuery,
|
||||||
getComicBooks,
|
useGetWantedComicsQuery,
|
||||||
} from "../../actions/fileops.actions";
|
useGetVolumeGroupsQuery,
|
||||||
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
|
useGetLibraryStatisticsQuery
|
||||||
import { isEmpty, isNil } from "lodash";
|
} from "../../graphql/generated";
|
||||||
|
|
||||||
export const Dashboard = (): ReactElement => {
|
export const Dashboard = (): ReactElement => {
|
||||||
const dispatch = useDispatch();
|
// Use GraphQL for recent comics
|
||||||
useEffect(() => {
|
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
|
||||||
dispatch(fetchVolumeGroups());
|
{ limit: 5 },
|
||||||
dispatch(
|
{ refetchOnWindowFocus: false }
|
||||||
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(
|
// Wanted Comics - using GraphQL
|
||||||
(state: RootState) => state.comicInfo.libraryStatistics,
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<>
|
||||||
<section className="section">
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<h1 className="title">Dashboard</h1>
|
<PullList />
|
||||||
|
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
|
||||||
{!isEmpty(recentComics) ? (
|
{/* Wanted comics */}
|
||||||
<>
|
<WantedComicsList comics={wantedComics} />
|
||||||
{/* Pull List */}
|
{/* Library Statistics */}
|
||||||
<PullList issues={recentComics} />
|
{statistics && <LibraryStatistics stats={statistics} />}
|
||||||
|
{/* Volume groups */}
|
||||||
{/* Stats */}
|
<VolumeGroups volumeGroups={volumeGroups} />
|
||||||
{!isEmpty(libraryStatistics) && (
|
</div>
|
||||||
<LibraryStatistics stats={libraryStatistics} />
|
</>
|
||||||
)}
|
|
||||||
{/* Wanted comics */}
|
|
||||||
{!isEmpty(wantedComics) && (
|
|
||||||
<WantedComicsList comics={wantedComics} />
|
|
||||||
)}
|
|
||||||
{/* Recent imports */}
|
|
||||||
<RecentlyImported comicBookCovers={recentComics} />
|
|
||||||
|
|
||||||
{/* Volumes */}
|
|
||||||
{!isEmpty(volumeGroups) && (
|
|
||||||
<VolumeGroups volumeGroups={volumeGroups} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ZeroState
|
|
||||||
header={"Set the source directory"}
|
|
||||||
message={
|
|
||||||
"No comics were found! Please point ThreeTwo! to a directory..."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
@@ -1,114 +1,105 @@
|
|||||||
import React, { ReactElement, useEffect } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import prettyBytes from "pretty-bytes";
|
import Header from "../shared/Header";
|
||||||
import { isEmpty, isUndefined, map } from "lodash";
|
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
||||||
|
|
||||||
|
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
||||||
|
comicDirectorySize: DirectorySize;
|
||||||
|
comicsMissingFiles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Props for {@link LibraryStatistics}. */
|
||||||
|
interface LibraryStatisticsProps {
|
||||||
|
stats: Stats | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a snapshot of library metrics: total comic files, tagging coverage,
|
||||||
|
* file-type breakdown, and the publisher with the most issues.
|
||||||
|
*
|
||||||
|
* Returns `null` when `stats` is absent or the statistics array is empty.
|
||||||
|
*/
|
||||||
|
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
|
||||||
|
if (!stats || !stats.totalDocuments) return null;
|
||||||
|
|
||||||
|
const facet = stats.statistics?.[0];
|
||||||
|
if (!facet) return null;
|
||||||
|
|
||||||
|
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
|
||||||
|
const topPublisher = publisherWithMostComicsInLibrary?.[0];
|
||||||
|
|
||||||
export const LibraryStatistics = (
|
|
||||||
props: ILibraryStatisticsProps,
|
|
||||||
): ReactElement => {
|
|
||||||
// const { stats } = props;
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<h4 className="title is-4">
|
<Header
|
||||||
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
|
headerContent="Your Library In Numbers"
|
||||||
</h4>
|
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
|
||||||
<p className="subtitle is-7">A brief snapshot of your library.</p>
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
<div className="columns is-multiline">
|
/>
|
||||||
<div className="column is-narrow is-two-quarter">
|
|
||||||
<dl className="box">
|
<div className="mt-3 flex flex-row gap-5">
|
||||||
<dd className="is-size-4">
|
{/* Total records in database */}
|
||||||
<span className="has-text-weight-bold">
|
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||||
{props.stats.totalDocuments}
|
<dt className="text-lg font-medium text-gray-500">In database</dt>
|
||||||
</span>{" "}
|
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||||
files
|
{stats.totalDocuments} comics
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing files */}
|
||||||
|
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
|
||||||
|
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
|
||||||
|
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||||
|
{stats.comicsMissingFiles}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disk space consumed */}
|
||||||
|
{stats.comicDirectorySize.totalSizeInGB != null && (
|
||||||
|
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||||
|
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
|
||||||
|
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||||
|
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
|
||||||
</dd>
|
</dd>
|
||||||
<dd className="is-size-4">
|
</div>
|
||||||
Library size
|
)}
|
||||||
<span className="has-text-weight-bold">
|
|
||||||
{" "}
|
{/* Tagging coverage */}
|
||||||
{props.stats.comicDirectorySize &&
|
<div className="flex flex-col gap-4">
|
||||||
prettyBytes(props.stats.comicDirectorySize)}
|
{issues && issues.length > 0 && (
|
||||||
|
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||||
|
<span className="text-xl text-gray-700">{issues.length}</span>
|
||||||
|
tagged with ComicVine
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
||||||
|
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||||
|
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
|
||||||
|
with ComicInfo.xml
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File-type breakdown */}
|
||||||
|
{fileTypes && fileTypes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{fileTypes.map((ft) => (
|
||||||
|
<span
|
||||||
|
key={ft.id}
|
||||||
|
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
|
||||||
|
>
|
||||||
|
{ft.data.length} {ft.id}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
))}
|
||||||
{!isUndefined(props.stats.statistics) &&
|
</div>
|
||||||
!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>
|
|
||||||
|
|
||||||
<div className="p-3 column is-one-quarter">
|
{/* Publisher with most issues */}
|
||||||
<dl className="box">
|
{topPublisher && (
|
||||||
<dd className="is-size-6">
|
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
|
||||||
<span className="has-text-weight-bold"></span> Issues
|
<span>{topPublisher.id}</span>
|
||||||
</dd>
|
{" has the most issues "}
|
||||||
<dd className="is-size-6">
|
<span>{topPublisher.count}</span>
|
||||||
<span className="has-text-weight-bold">304</span> Volumes
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,164 +1,182 @@
|
|||||||
import { isNil, map } from "lodash";
|
import React, { ReactElement, useState } from "react";
|
||||||
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
|
import { map } from "lodash";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
import Masonry from "react-masonry-css";
|
import Header from "../shared/Header";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
|
||||||
import { importToDB } from "../../actions/fileops.actions";
|
|
||||||
import ellipsize from "ellipsize";
|
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";
|
|
||||||
|
|
||||||
type PullListProps = {
|
import axios from "axios";
|
||||||
issues: any;
|
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";
|
||||||
|
|
||||||
export const PullList = ({ issues }: PullListProps): ReactElement => {
|
interface PullListProps {
|
||||||
const dispatch = useDispatch();
|
issues?: LocgMetadata[];
|
||||||
useEffect(() => {
|
}
|
||||||
dispatch(
|
|
||||||
getWeeklyPullList({
|
export const PullList = (): ReactElement => {
|
||||||
startDate: "2022-12-25",
|
const queryClient = useQueryClient();
|
||||||
pageSize: "15",
|
|
||||||
currentPage: "1",
|
// datepicker
|
||||||
}),
|
const date = new Date();
|
||||||
);
|
const [inputValue, setInputValue] = useState<string>(
|
||||||
}, []);
|
format(date, "yyyy/M/dd"),
|
||||||
const addToLibrary = useCallback(
|
|
||||||
(sourceName: string, locgMetadata) =>
|
|
||||||
dispatch(importToDB(sourceName, { locg: locgMetadata })),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
/*
|
|
||||||
const foo = {
|
|
||||||
coverFile: {}, // pointer to which cover file to use
|
|
||||||
rawFileDetails: {}, // #1
|
|
||||||
sourcedMetadata: {
|
|
||||||
comicInfo: {},
|
|
||||||
comicvine: {}, // #2
|
|
||||||
locg: {}, // #2
|
|
||||||
},
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|
||||||
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
|
// embla carousel
|
||||||
let sliderRef = createRef();
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
const settings = {
|
loop: false,
|
||||||
dots: false,
|
align: "start",
|
||||||
infinite: false,
|
containScroll: "trimSnaps",
|
||||||
speed: 500,
|
slidesToScroll: 1,
|
||||||
slidesToShow: 5,
|
});
|
||||||
slidesToScroll: 5,
|
|
||||||
initialSlide: 0,
|
const {
|
||||||
responsive: [
|
data: pullListData,
|
||||||
{
|
refetch,
|
||||||
breakpoint: 1024,
|
isSuccess,
|
||||||
settings: {
|
isLoading,
|
||||||
slidesToShow: 3,
|
isError,
|
||||||
slidesToScroll: 3,
|
} = useGetWeeklyPullListQuery({
|
||||||
infinite: false,
|
input: {
|
||||||
|
startDate: inputValue,
|
||||||
|
pageSize: 15,
|
||||||
|
currentPage: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the data to match the old structure
|
||||||
|
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined;
|
||||||
|
|
||||||
|
const { mutate: addToLibrary } = useMutation({
|
||||||
|
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => {
|
||||||
|
const comicBookMetadata = {
|
||||||
|
importType: "new",
|
||||||
|
payload: {
|
||||||
|
rawFileDetails: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
importStatus: {
|
||||||
|
isImported: true,
|
||||||
|
tagged: false,
|
||||||
|
matchedResult: {
|
||||||
|
score: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourcedMetadata: metadata || null,
|
||||||
|
acquisition: { source: { wanted: true, name: sourceName } },
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
{
|
|
||||||
breakpoint: 600,
|
return await axios.request({
|
||||||
settings: {
|
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||||
slidesToShow: 2,
|
method: "POST",
|
||||||
slidesToScroll: 2,
|
data: comicBookMetadata,
|
||||||
initialSlide: 0,
|
});
|
||||||
},
|
},
|
||||||
},
|
onSuccess: () => {
|
||||||
{
|
// Invalidate and refetch wanted comics queries
|
||||||
breakpoint: 480,
|
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||||
settings: {
|
},
|
||||||
slidesToShow: 1,
|
});
|
||||||
slidesToScroll: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
sliderRef.slickNext();
|
// sliderRef.slickNext();
|
||||||
};
|
};
|
||||||
const previous = () => {
|
const previous = () => {
|
||||||
sliderRef.slickPrev();
|
// sliderRef.slickPrev();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="content">
|
<Header
|
||||||
<h4 className="title is-4">
|
headerContent="Discover"
|
||||||
<i className="fa-solid fa-splotch"></i> Discover
|
subHeaderContent={
|
||||||
</h4>
|
<span className="text-md">
|
||||||
<p className="subtitle is-7">
|
Pull List aggregated for the week from{" "}
|
||||||
Pull List aggregated for the week from League Of Comic Geeks
|
<span className="underline">
|
||||||
</p>
|
<a href="https://leagueofcomicgeeks.com">
|
||||||
<div className="field is-grouped">
|
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">
|
||||||
{/* select week */}
|
{/* select week */}
|
||||||
<div className="control">
|
<div className="flex flex-row gap-4 my-3">
|
||||||
<div className="select is-small">
|
<Form
|
||||||
<select>
|
onSubmit={() => {}}
|
||||||
<option>Select Week</option>
|
render={({ handleSubmit }) => (
|
||||||
<option>With options</option>
|
<form>
|
||||||
</select>
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
{/* week selection for pull list */}
|
||||||
</div>
|
<DatePickerDialog
|
||||||
{/* See all pull list issues */}
|
inputValue={inputValue}
|
||||||
<div className="control">
|
setter={setInputValue}
|
||||||
<Link to={"/pull-list/all/"}>
|
/>
|
||||||
<button className="button is-small">View all issues</button>
|
{inputValue && (
|
||||||
</Link>
|
<div className="text-sm">
|
||||||
</div>
|
Showing pull list for{" "}
|
||||||
<div className="field has-addons">
|
<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">
|
||||||
<div className="control">
|
{inputValue}
|
||||||
<button className="button is-rounded is-small" onClick={previous}>
|
</span>
|
||||||
<i className="fa-solid fa-caret-left"></i>
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="control">
|
</form>
|
||||||
<button className="button is-rounded is-small" onClick={next}>
|
)}
|
||||||
<i className="fa-solid fa-caret-right"></i>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="w-lvw -mr-4 sm:-mr-6 lg:-mr-8">
|
||||||
<Slider {...settings} ref={(c) => (sliderRef = c)}>
|
{isSuccess && !isLoading && (
|
||||||
{!isNil(pullList) &&
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
pullList &&
|
<div className="flex">
|
||||||
map(pullList, ({issue}, idx) => {
|
{map(pullList?.data.result, (issue: LocgMetadata, idx: number) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
orientation={"vertical"}
|
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]"
|
||||||
imageUrl={issue.cover}
|
>
|
||||||
hasDetails
|
<Card
|
||||||
title={ellipsize(issue.name, 18)}
|
orientation={"vertical-2"}
|
||||||
cardContainerStyle={{
|
imageUrl={issue.cover || undefined}
|
||||||
marginRight: 22,
|
hasDetails
|
||||||
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
|
title={ellipsize(issue.name || 'Unknown', 25)}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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
|
<div className="px-1">
|
||||||
</button>
|
<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>
|
||||||
</div>
|
);
|
||||||
</Card>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</Slider>
|
)}
|
||||||
|
{isLoading && <div>Loading...</div>}
|
||||||
|
{isError && <div>An error occurred while retrieving the pull list.</div>}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PullList;
|
export default PullList;
|
||||||
|
|||||||
@@ -1,146 +1,144 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import Masonry from "react-masonry-css";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import {
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
determineCoverFile,
|
import Header from "../shared/Header";
|
||||||
determineExternalMetadata,
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
} from "../../shared/utils/metadata.utils";
|
import { GetRecentComicsQuery } from "../../graphql/generated";
|
||||||
|
|
||||||
type RecentlyImportedProps = {
|
type RecentlyImportedProps = {
|
||||||
comicBookCovers: any;
|
comics: GetRecentComicsQuery['comics']['comics'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecentlyImported = ({
|
export const RecentlyImported = (
|
||||||
comicBookCovers,
|
{ comics }: RecentlyImportedProps,
|
||||||
}: RecentlyImportedProps): ReactElement => {
|
): ReactElement => {
|
||||||
const breakpointColumnsObj = {
|
// embla carousel
|
||||||
default: 5,
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
1100: 4,
|
loop: false,
|
||||||
700: 2,
|
align: "start",
|
||||||
600: 2,
|
containScroll: "trimSnaps",
|
||||||
};
|
slidesToScroll: 1,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div className="content mt-5">
|
<Header
|
||||||
<h4 className="title is-4">
|
headerContent="Recently Imported"
|
||||||
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
|
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||||
</h4>
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
<p className="subtitle is-7">
|
/>
|
||||||
Recent Library activity such as imports, tagging, etc.
|
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||||
</p>
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
</div>
|
<div className="flex">
|
||||||
<Masonry
|
{comics?.map((comic, idx) => {
|
||||||
breakpointCols={breakpointColumnsObj}
|
const {
|
||||||
className="recent-comics-container"
|
id,
|
||||||
columnClassName="recent-comics-column"
|
|
||||||
>
|
|
||||||
{map(
|
|
||||||
comicBookCovers,
|
|
||||||
(
|
|
||||||
{
|
|
||||||
_id,
|
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
sourcedMetadata,
|
||||||
acquisition: {
|
canonicalMetadata,
|
||||||
source: { name },
|
inferredMetadata,
|
||||||
},
|
importStatus,
|
||||||
},
|
} = comic;
|
||||||
idx,
|
|
||||||
) => {
|
// Parse sourced metadata (GraphQL returns as strings)
|
||||||
|
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||||
|
? JSON.parse(sourcedMetadata.comicvine)
|
||||||
|
: sourcedMetadata?.comicvine;
|
||||||
|
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||||
|
? JSON.parse(sourcedMetadata.comicInfo)
|
||||||
|
: sourcedMetadata?.comicInfo;
|
||||||
|
const locg = sourcedMetadata?.locg;
|
||||||
|
|
||||||
const { issueName, url } = determineCoverFile({
|
const { issueName, url } = determineCoverFile({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
comicvine,
|
comicvine,
|
||||||
comicInfo,
|
comicInfo,
|
||||||
locg,
|
locg,
|
||||||
});
|
});
|
||||||
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
const isComicVineMetadataAvailable =
|
||||||
comicvine,
|
|
||||||
comicInfo,
|
|
||||||
locg,
|
|
||||||
});
|
|
||||||
const isComicBookMetadataAvailable =
|
|
||||||
!isUndefined(comicvine) &&
|
!isUndefined(comicvine) &&
|
||||||
!isUndefined(comicvine.volumeInformation);
|
!isUndefined(comicvine.volumeInformation);
|
||||||
|
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||||
const titleElement = (
|
const isMissingFile = importStatus?.isRawFileMissing === true;
|
||||||
<Link to={"/comic/details/" + _id}>
|
const cardState = isMissingFile
|
||||||
{ellipsize(issueName, 20)}
|
? "missing"
|
||||||
</Link>
|
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={_id}>
|
<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
|
<Card
|
||||||
orientation={"vertical"}
|
orientation="vertical-2"
|
||||||
imageUrl={url}
|
imageUrl={url}
|
||||||
|
title={inferredMetadata?.issue?.name}
|
||||||
hasDetails
|
hasDetails
|
||||||
title={issueName ? titleElement : null}
|
cardState={cardState}
|
||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div>
|
||||||
{/* Raw file presence */}
|
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||||
{isNil(rawFileDetails) && (
|
{/* Issue number */}
|
||||||
<span className="icon custom-icon is-small has-text-danger mr-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">
|
||||||
<img src="/img/missing-file.svg" />
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--hashtag-outline]"></i>
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="text-md text-slate-900">
|
||||||
{/* ComicInfo.xml presence */}
|
{inferredMetadata?.issue?.number}
|
||||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
|
||||||
<span className="icon custom-icon is-small has-text-danger">
|
|
||||||
<img
|
|
||||||
src="/img/comicinfoxml.svg"
|
|
||||||
alt={"ComicInfo.xml file detected."}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
{/* ComicVine metadata presence */}
|
{/* File extension */}
|
||||||
{isComicBookMetadataAvailable && (
|
<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="icon custom-icon">
|
<span className="pr-1 pt-1">
|
||||||
<img
|
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
|
||||||
src="/img/cvlogo.svg"
|
|
||||||
alt={"ComicInfo.xml file detected."}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{/* Issue type */}
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
{isComicBookMetadataAvailable &&
|
{rawFileDetails?.extension}
|
||||||
!isNil(
|
</span>
|
||||||
detectIssueTypes(comicvine.volumeInformation.description),
|
</span>
|
||||||
) ? (
|
{/* Uncompressed status */}
|
||||||
<span className="tag is-warning">
|
{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">
|
||||||
detectIssueTypes(
|
<span className="pr-1 pt-1">
|
||||||
comicvine.volumeInformation.description,
|
<i className="icon-[solar--bookmark-bold-duotone] w-4 h-4"></i>
|
||||||
).displayName
|
</span>
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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">
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
|
alt={"ComicVine metadata detected."}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
{/* metadata card */}
|
</Card>
|
||||||
{!isNil(name) && (
|
</div>
|
||||||
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
|
|
||||||
<dd className="is-size-9">
|
|
||||||
<dl>
|
|
||||||
<span className="icon custom-icon">
|
|
||||||
<img src={`/img/${icon}`} />
|
|
||||||
</span>
|
|
||||||
</dl>
|
|
||||||
<dl>
|
|
||||||
<span className="small-tag">
|
|
||||||
{ellipsize(issue, 15)}
|
|
||||||
</span>
|
|
||||||
</dl>
|
|
||||||
</dd>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
},
|
})}
|
||||||
)}
|
</div>
|
||||||
</Masonry>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,62 +2,83 @@ import { map, unionBy } from "lodash";
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import Masonry from "react-masonry-css";
|
import Card from "../shared/Carda";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
import { GetVolumeGroupsQuery } from "../../graphql/generated";
|
||||||
|
|
||||||
export const VolumeGroups = (props): ReactElement => {
|
type VolumeGroupsProps = {
|
||||||
const breakpointColumnsObj = {
|
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
||||||
default: 5,
|
};
|
||||||
1100: 4,
|
|
||||||
700: 2,
|
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
|
||||||
500: 1,
|
|
||||||
};
|
|
||||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||||
|
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToVolumes = (row) => {
|
const navigateToVolumes = (row: any) => {
|
||||||
navigate(`/volumes/all`);
|
navigate(`/volumes/all`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// embla carousel
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
loop: false,
|
||||||
|
align: "start",
|
||||||
|
containScroll: "trimSnaps",
|
||||||
|
slidesToScroll: 1,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="volumes-container mt-4">
|
<div>
|
||||||
<div className="content">
|
<Header
|
||||||
<a className="mb-1" onClick={navigateToVolumes}>
|
headerContent="Volumes"
|
||||||
<span className="is-size-4 has-text-weight-semibold">
|
subHeaderContent={<>Based on ComicVine Volume information</>}
|
||||||
<i className="fa-solid fa-layer-group"></i> Volumes
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
</span>
|
link={"/volumes"}
|
||||||
<span className="icon mt-1">
|
/>
|
||||||
<i className="fa-solid fa-angle-right"></i>
|
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||||
</span>
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
</a>
|
<div className="flex">
|
||||||
<p className="subtitle is-7">Based on ComicVine Volume information</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<Masonry
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,138 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import Masonry from "react-masonry-css";
|
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
import { GetWantedComicsQuery } from "../../graphql/generated";
|
||||||
|
|
||||||
type WantedComicsListProps = {
|
type WantedComicsListProps = {
|
||||||
comics: any;
|
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WantedComicsList = ({
|
export const WantedComicsList = ({
|
||||||
comics,
|
comics,
|
||||||
}: WantedComicsListProps): ReactElement => {
|
}: WantedComicsListProps): ReactElement | null => {
|
||||||
const breakpointColumnsObj = {
|
if (!comics || comics.length === 0) return null;
|
||||||
default: 5,
|
|
||||||
1100: 4,
|
|
||||||
700: 2,
|
|
||||||
500: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToWantedComics = (row) => {
|
|
||||||
navigate(`/wanted/all`);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { issueName, url } = determineCoverFile(
|
// embla carousel
|
||||||
consolidatedComicMetadata,
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
);
|
loop: false,
|
||||||
const titleElement = (
|
align: "start",
|
||||||
<Link to={"/comic/details/" + _id}>
|
containScroll: "trimSnaps",
|
||||||
{ellipsize(issueName, 20)}
|
slidesToScroll: 1,
|
||||||
</Link>
|
});
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div>
|
||||||
key={_id}
|
<Header
|
||||||
orientation={"vertical"}
|
headerContent="Wanted Comics"
|
||||||
imageUrl={url}
|
subHeaderContent={<>Comics marked as wanted from various sources</>}
|
||||||
hasDetails
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
title={issueName ? titleElement : <span>No Name</span>}
|
link={"/wanted"}
|
||||||
>
|
/>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||||
{/* comicVine metadata presence */}
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
{isComicBookMetadataAvailable && (
|
<div className="flex">
|
||||||
<span className="icon custom-icon">
|
{map(
|
||||||
<img src="/img/cvlogo.svg" />
|
comics,
|
||||||
</span>
|
(comic) => {
|
||||||
)}
|
const {
|
||||||
{!isEmpty(locg) && (
|
id,
|
||||||
<span className="icon custom-icon">
|
rawFileDetails,
|
||||||
<img src="/img/locglogo.svg" />
|
sourcedMetadata,
|
||||||
</span>
|
} = comic;
|
||||||
)}
|
|
||||||
{/* Issue type */}
|
// Parse sourced metadata (GraphQL returns as strings)
|
||||||
{isComicBookMetadataAvailable &&
|
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||||
!isNil(
|
? JSON.parse(sourcedMetadata.comicvine)
|
||||||
detectIssueTypes(comicvine.volumeInformation.description),
|
: sourcedMetadata?.comicvine;
|
||||||
) ? (
|
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||||
<span className="tag is-warning">
|
? JSON.parse(sourcedMetadata.comicInfo)
|
||||||
{
|
: sourcedMetadata?.comicInfo;
|
||||||
detectIssueTypes(
|
const locg = sourcedMetadata?.locg;
|
||||||
comicvine.volumeInformation.description,
|
|
||||||
).displayName
|
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||||
}
|
const consolidatedComicMetadata = {
|
||||||
</span>
|
rawFileDetails,
|
||||||
) : null}
|
comicvine,
|
||||||
</div>
|
comicInfo,
|
||||||
</Card>
|
locg,
|
||||||
);
|
};
|
||||||
},
|
|
||||||
)}
|
const {
|
||||||
</Masonry>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ interface ZeroStateProps {
|
|||||||
}
|
}
|
||||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<article className="message is-info">
|
<article className="">
|
||||||
<div className="message-body">
|
<div className="">
|
||||||
<p>{props.header}</p>
|
<p>{props.header}</p>
|
||||||
{props.message}
|
{props.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
|
import React, { ReactElement, useEffect, useState } from "react";
|
||||||
import { getTransfers } from "../../actions/airdcpp.actions";
|
import { getTransfers } from "../../actions/airdcpp.actions";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
|
||||||
@@ -12,16 +9,18 @@ interface IDownloadsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Downloads = (props: IDownloadsProps): ReactElement => {
|
export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
const {
|
const {
|
||||||
airDCPPState: { settings, socket },
|
airDCPPState: { settings, socket },
|
||||||
} = airDCPPConfiguration;
|
} = airDCPPConfiguration;
|
||||||
const dispatch = useDispatch();
|
// const dispatch = useDispatch();
|
||||||
|
|
||||||
const airDCPPTransfers = useSelector(
|
// const airDCPPTransfers = useSelector(
|
||||||
(state: RootState) => state.airdcpp.transfers,
|
// (state: RootState) => state.airdcpp.transfers,
|
||||||
);
|
// );
|
||||||
const issueBundles = useSelector((state: RootState) => state.airdcpp.issue_bundles);
|
// const issueBundles = useSelector(
|
||||||
|
// (state: RootState) => state.airdcpp.issue_bundles,
|
||||||
|
// );
|
||||||
const [bundles, setBundles] = useState([]);
|
const [bundles, setBundles] = useState([]);
|
||||||
// Make the call to get all transfers from AirDC++
|
// Make the call to get all transfers from AirDC++
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,62 +37,85 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUndefined(issueBundles)) {
|
if (!isUndefined(issueBundles)) {
|
||||||
const foo = issueBundles.data.map((bundle) => {
|
const foo = issueBundles.data.map((bundle) => {
|
||||||
const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
|
const {
|
||||||
|
rawFileDetails,
|
||||||
|
inferredMetadata,
|
||||||
|
acquisition: {
|
||||||
|
directconnect: { downloads },
|
||||||
|
},
|
||||||
|
sourcedMetadata: { locg, comicvine },
|
||||||
|
} = bundle;
|
||||||
const { issueName, url } = determineCoverFile({
|
const { issueName, url } = determineCoverFile({
|
||||||
rawFileDetails, comicvine, locg,
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
locg,
|
||||||
});
|
});
|
||||||
return { ...bundle, issueName, url }
|
return { ...bundle, issueName, url };
|
||||||
|
});
|
||||||
})
|
|
||||||
setBundles(foo);
|
setBundles(foo);
|
||||||
}
|
}
|
||||||
|
}, [issueBundles]);
|
||||||
|
|
||||||
}, [issueBundles])
|
return !isNil(bundles) ? (
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
return !isNil(bundles) ?
|
|
||||||
<div className="container">
|
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h1 className="title">Downloads</h1>
|
<h1 className="title">Downloads</h1>
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
{bundles.map(bundle => {
|
{bundles.map((bundle, idx) => {
|
||||||
console.log(bundle);
|
return (
|
||||||
return <>
|
<div key={idx}>
|
||||||
<MetadataPanel
|
<MetadataPanel
|
||||||
data={bundle}
|
data={bundle}
|
||||||
imageStyle={{ maxWidth: 80 }}
|
imageStyle={{ maxWidth: 80 }}
|
||||||
titleStyle={{ fontSize: "0.8rem" }}
|
titleStyle={{ fontSize: "0.8rem" }}
|
||||||
tagsStyle={{ fontSize: "0.7rem" }}
|
tagsStyle={{ fontSize: "0.7rem" }}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: "0 0 8px 0",
|
margin: "0 0 8px 0",
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<table className="table is-size-7">
|
<table className="table is-size-7">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Name</th>
|
<tr>
|
||||||
<th>Size</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
<th>Size</th>
|
||||||
<th>Bundle ID</th>
|
<th>Type</th>
|
||||||
</tr>
|
<th>Bundle ID</th>
|
||||||
|
</tr>
|
||||||
{bundle.acquisition.directconnect.downloads.map((bundle) => {
|
</thead>
|
||||||
return(<tr>
|
<tbody>
|
||||||
<td>{bundle.name}</td>
|
{bundle.acquisition.directconnect.downloads.map(
|
||||||
<td>{bundle.size}</td>
|
(bundle, idx) => {
|
||||||
<td>{bundle.type.str}</td>
|
return (
|
||||||
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
|
<tr key={idx}>
|
||||||
</tr>)
|
<td>{bundle.name}</td>
|
||||||
})}
|
<td>{bundle.size}</td>
|
||||||
|
<td>{bundle.type.str}</td>
|
||||||
|
<td>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{bundle.bundleId}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
|
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
|
||||||
</>
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div> : <div>asd</div>;
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>There are no downloads.</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Downloads;
|
export default Downloads;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { debounce, isEmpty, map } from "lodash";
|
import { debounce, isEmpty, map } from "lodash";
|
||||||
import React, { ReactElement, useCallback, useState } from "react";
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
|
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
@@ -31,7 +31,7 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
|||||||
from: 0,
|
from: 0,
|
||||||
},
|
},
|
||||||
type: "volumeName",
|
type: "volumeName",
|
||||||
trigger: "globalSearchBar"
|
trigger: "globalSearchBar",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -51,7 +51,6 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
|||||||
<i className="fa-solid fa-magnifying-glass"></i>
|
<i className="fa-solid fa-magnifying-glass"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEmpty(searchResults) ? (
|
{!isEmpty(searchResults) ? (
|
||||||
<div
|
<div
|
||||||
className="columns box is-multiline"
|
className="columns box is-multiline"
|
||||||
@@ -62,9 +61,10 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
|||||||
margin: "60px 0 0 350px",
|
margin: "60px 0 0 350px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{map(searchResults, (result) => (
|
{map(searchResults, (result, idx) => (
|
||||||
<MetadataPanel
|
<MetadataPanel
|
||||||
data={result}
|
data={result}
|
||||||
|
key={idx}
|
||||||
imageStyle={{ maxWidth: 70 }}
|
imageStyle={{ maxWidth: 70 }}
|
||||||
titleStyle={{ fontSize: "0.8rem" }}
|
titleStyle={{ fontSize: "0.8rem" }}
|
||||||
tagsStyle={{ fontSize: "0.7rem" }}
|
tagsStyle={{ fontSize: "0.7rem" }}
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import React, { ReactElement, useCallback, useContext, useState } from "react";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
import {
|
|
||||||
fetchComicBookMetadata,
|
|
||||||
toggleImportQueueStatus,
|
|
||||||
} from "../actions/fileops.actions";
|
|
||||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
|
||||||
import Loader from "react-loader-spinner";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
matches?: unknown;
|
|
||||||
fetchComicMetadata?: any;
|
|
||||||
path: string;
|
|
||||||
covers?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the average of two numbers.
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
|
|
||||||
*
|
|
||||||
* @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 dispatch = useDispatch();
|
|
||||||
const libraryQueueResults = useSelector(
|
|
||||||
(state: RootState) => state.fileOps.librarySearchResultCount,
|
|
||||||
);
|
|
||||||
const libraryQueueImportStatus = useSelector(
|
|
||||||
(state: RootState) => state.fileOps.IMSCallInProgress,
|
|
||||||
);
|
|
||||||
const [isImportQueuePaused, setImportQueueStatus] = useState(false);
|
|
||||||
const initiateImport = useCallback(() => {
|
|
||||||
if (typeof props.path !== "undefined") {
|
|
||||||
dispatch(fetchComicBookMetadata(props.path));
|
|
||||||
}
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const toggleImport = useCallback(() => {
|
|
||||||
setImportQueueStatus(!isImportQueuePaused);
|
|
||||||
if (isImportQueuePaused === false) {
|
|
||||||
dispatch(toggleImportQueueStatus({ action: "resume" }));
|
|
||||||
} else if (isImportQueuePaused === true) {
|
|
||||||
dispatch(toggleImportQueueStatus({ action: "pause" }));
|
|
||||||
}
|
|
||||||
}, [isImportQueuePaused]);
|
|
||||||
const pauseIconText = (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-pause mr-2"></i> Pause Import
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const playIconText = (
|
|
||||||
<>
|
|
||||||
<i className="fa-solid fa-play mr-2"></i> Resume Import
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<section className="section is-small">
|
|
||||||
<h1 className="title">Import</h1>
|
|
||||||
<article className="message is-dark">
|
|
||||||
<div className="message-body">
|
|
||||||
<p className="mb-2">
|
|
||||||
<span className="tag is-medium is-info is-light">
|
|
||||||
Import Only
|
|
||||||
</span>
|
|
||||||
will add comics identified from the mapped folder into the local
|
|
||||||
db.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="tag is-medium is-info is-light">
|
|
||||||
Import and Tag
|
|
||||||
</span>
|
|
||||||
will scan the ComicVine, shortboxed APIs and import comics from
|
|
||||||
the mapped folder with the additional metadata.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<p className="buttons">
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
libraryQueueImportStatus
|
|
||||||
? "button is-loading is-medium"
|
|
||||||
: "button is-medium"
|
|
||||||
}
|
|
||||||
onClick={initiateImport}
|
|
||||||
>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-file-import"></i>
|
|
||||||
</span>
|
|
||||||
<span>Import Only</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="button is-medium">
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-tag"></i>
|
|
||||||
</span>
|
|
||||||
<span>Import and Tag</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<div className="columns is-multiline">
|
|
||||||
<div className="column is-one-fifth">
|
|
||||||
<div className="box control-palette">
|
|
||||||
<span className="is-size-2 has-text-weight-bold">
|
|
||||||
{JSON.stringify(libraryQueueResults, null, 2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="is-half">
|
|
||||||
<div className="content">
|
|
||||||
<div className="control">
|
|
||||||
<button
|
|
||||||
className="button is-warning is-light"
|
|
||||||
onClick={toggleImport}
|
|
||||||
>
|
|
||||||
{!isImportQueuePaused ? pauseIconText : playIconText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Import;
|
|
||||||
493
src/client/components/Import/Import.test.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Import } from './Import';
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.MockedFunction<any>;
|
||||||
|
|
||||||
|
// Mock zustand store
|
||||||
|
const mockGetSocket = jest.fn();
|
||||||
|
const mockDisconnectSocket = jest.fn();
|
||||||
|
const mockSetStatus = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../store', () => ({
|
||||||
|
useStore: jest.fn((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'drained',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock socket.io-client
|
||||||
|
const mockSocket = {
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
emit: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetSocket.mockReturnValue(mockSocket);
|
||||||
|
|
||||||
|
// Helper function to create a wrapper with QueryClient
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Import Component - Numerical Indices', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display numerical indices in the Past Imports table', async () => {
|
||||||
|
// Mock API response with 3 import sessions
|
||||||
|
const mockData = [
|
||||||
|
{
|
||||||
|
sessionId: 'session-1',
|
||||||
|
earliestTimestamp: '2024-01-01T10:00:00Z',
|
||||||
|
completedJobs: 5,
|
||||||
|
failedJobs: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'session-2',
|
||||||
|
earliestTimestamp: '2024-01-02T10:00:00Z',
|
||||||
|
completedJobs: 3,
|
||||||
|
failedJobs: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 'session-3',
|
||||||
|
earliestTimestamp: '2024-01-03T10:00:00Z',
|
||||||
|
completedJobs: 8,
|
||||||
|
failedJobs: 2
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(axios as any).mockResolvedValue({ data: mockData });
|
||||||
|
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for the "Past Imports" heading to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the "#" column header exists
|
||||||
|
expect(screen.getByText('#')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
|
||||||
|
const rows = screen.getAllByRole('row');
|
||||||
|
// Skip header row (index 0), check data rows
|
||||||
|
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
|
||||||
|
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
|
||||||
|
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct indices for larger datasets', async () => {
|
||||||
|
// Mock API response with 10 import sessions
|
||||||
|
const mockData = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
sessionId: `session-${i + 1}`,
|
||||||
|
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||||
|
completedJobs: i + 1,
|
||||||
|
failedJobs: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(axios as any).mockResolvedValue({ data: mockData });
|
||||||
|
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify indices 1 through 10 are present in the first column
|
||||||
|
const rows = screen.getAllByRole('row');
|
||||||
|
// Skip header row (index 0)
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
expect(cells[0]).toHaveTextContent(i.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Import Component - Button Visibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(axios as any).mockResolvedValue({ data: [] });
|
||||||
|
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show Start Import button when queue status is drained', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'drained',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Pause and Resume buttons are NOT visible
|
||||||
|
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show Start Import button when queue status is undefined', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: undefined,
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide Start Import button and show Pause button when queue is running', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'running',
|
||||||
|
successfulJobCount: 5,
|
||||||
|
failedJobCount: 1,
|
||||||
|
mostRecentImport: 'Comic #123',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pause')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Import Activity section is visible
|
||||||
|
expect(screen.getByText('Import Activity')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide Start Import button and show Resume button when queue is paused', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'paused',
|
||||||
|
successfulJobCount: 3,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: 'Comic #456',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Resume')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Import Component - SessionId and Socket Reconnection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
(axios as any).mockResolvedValue({ data: [] });
|
||||||
|
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
|
||||||
|
// Setup: Set old sessionId in localStorage
|
||||||
|
localStorage.setItem('sessionId', 'old-session-id');
|
||||||
|
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'drained',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Click the "Start Import" button
|
||||||
|
const startButton = await screen.findByText('Start Import');
|
||||||
|
fireEvent.click(startButton);
|
||||||
|
|
||||||
|
// Verify sessionId is cleared immediately
|
||||||
|
expect(localStorage.getItem('sessionId')).toBeNull();
|
||||||
|
|
||||||
|
// Verify disconnectSocket is called
|
||||||
|
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
|
||||||
|
|
||||||
|
// Fast-forward 100ms
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify getSocket is called after 100ms
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetSocket).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward another 500ms
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify initiateImport is called and status is set to running
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios.request).toHaveBeenCalledWith({
|
||||||
|
url: 'http://localhost:3000/api/library/newImport',
|
||||||
|
method: 'POST',
|
||||||
|
data: { sessionId: null },
|
||||||
|
});
|
||||||
|
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should NOT clear sessionId when starting import with undefined status', async () => {
|
||||||
|
// Setup: Set existing sessionId in localStorage
|
||||||
|
localStorage.setItem('sessionId', 'existing-session-id');
|
||||||
|
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: undefined,
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Click the "Start Import" button
|
||||||
|
const startButton = await screen.findByText('Start Import');
|
||||||
|
fireEvent.click(startButton);
|
||||||
|
|
||||||
|
// Verify sessionId is NOT cleared
|
||||||
|
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
|
||||||
|
|
||||||
|
// Verify disconnectSocket is NOT called
|
||||||
|
expect(mockDisconnectSocket).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify status is set to running immediately
|
||||||
|
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
||||||
|
|
||||||
|
// Verify initiateImport is called immediately (no delay)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios.request).toHaveBeenCalledWith({
|
||||||
|
url: 'http://localhost:3000/api/library/newImport',
|
||||||
|
method: 'POST',
|
||||||
|
data: { sessionId: 'existing-session-id' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Import Component - Real-time Updates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(axios as any).mockResolvedValue({ data: [] });
|
||||||
|
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'running',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for component to mount and socket listeners to be attached
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the event handler that was registered
|
||||||
|
const coverExtractedHandler = mockSocket.on.mock.calls.find(
|
||||||
|
(call) => call[0] === 'LS_COVER_EXTRACTED'
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
// Clear previous axios calls
|
||||||
|
(axios as any).mockClear();
|
||||||
|
|
||||||
|
// Simulate the socket event
|
||||||
|
if (coverExtractedHandler) {
|
||||||
|
coverExtractedHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API is called again (refetch)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'running',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for component to mount and socket listeners to be attached
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the event handler that was registered
|
||||||
|
const queueDrainedHandler = mockSocket.on.mock.calls.find(
|
||||||
|
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
// Clear previous axios calls
|
||||||
|
(axios as any).mockClear();
|
||||||
|
|
||||||
|
// Simulate the socket event
|
||||||
|
if (queueDrainedHandler) {
|
||||||
|
queueDrainedHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API is called again (refetch)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axios).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should cleanup socket listeners on unmount', async () => {
|
||||||
|
const { useStore } = require('../../store');
|
||||||
|
useStore.mockImplementation((selector: any) =>
|
||||||
|
selector({
|
||||||
|
importJobQueue: {
|
||||||
|
status: 'drained',
|
||||||
|
successfulJobCount: 0,
|
||||||
|
failedJobCount: 0,
|
||||||
|
mostRecentImport: '',
|
||||||
|
setStatus: mockSetStatus,
|
||||||
|
},
|
||||||
|
getSocket: mockGetSocket,
|
||||||
|
disconnectSocket: mockDisconnectSocket,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for socket listeners to be attached
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSocket.on).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unmount the component
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Verify that socket listeners are removed
|
||||||
|
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
||||||
|
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
||||||
308
src/client/components/Import/Import.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||||
|
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||||
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
|
export const Import = (): ReactElement => {
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
importJobQueue: state.importJobQueue,
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
disconnectSocket: state.disconnectSocket,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force re-import mutation - re-imports all files regardless of import status
|
||||||
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
return await axios.request({
|
||||||
|
url: `http://localhost:3000/api/library/forceReImport`,
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
console.log("Force re-import initiated:", response.data);
|
||||||
|
importJobQueue.setStatus("running");
|
||||||
|
setImportError(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("Failed to start force re-import:", error);
|
||||||
|
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, 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]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles force re-import - re-imports all files to fix indexing issues
|
||||||
|
*/
|
||||||
|
const handleForceReImport = async () => {
|
||||||
|
setImportError(null);
|
||||||
|
|
||||||
|
// Check for active session before starting using definitive status
|
||||||
|
if (hasActiveSession) {
|
||||||
|
setImportError(
|
||||||
|
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm(
|
||||||
|
"This will re-import ALL files in your library folder, even those already imported. " +
|
||||||
|
"This can help fix Elasticsearch indexing issues. Continue?"
|
||||||
|
)) {
|
||||||
|
if (importJobQueue.status === "drained") {
|
||||||
|
localStorage.removeItem("sessionId");
|
||||||
|
disconnectSocket("/");
|
||||||
|
setTimeout(() => {
|
||||||
|
getSocket("/");
|
||||||
|
setTimeout(() => {
|
||||||
|
forceReImport();
|
||||||
|
}, 500);
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
forceReImport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
|
<div className="mx-auto max-w-screen-xl px-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">
|
||||||
|
Import
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<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="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="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>
|
||||||
|
Importing will add comics identified from the mapped folder into
|
||||||
|
ThreeTwo's database.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Metadata from ComicInfo.xml, if present, will also be extracted.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This process could take a while, if you have a lot of comics, or
|
||||||
|
are importing over a network connection.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
onClick={() => setImportError(null)}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||||
|
>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Force Re-Import Button - always shown when no import is running */}
|
||||||
|
{!hasActiveSession &&
|
||||||
|
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||||
|
<div className="my-6 max-w-screen-lg">
|
||||||
|
<button
|
||||||
|
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={handleForceReImport}
|
||||||
|
disabled={isForceReImporting || hasActiveSession}
|
||||||
|
title="Re-import all files to fix Elasticsearch indexing issues"
|
||||||
|
>
|
||||||
|
<span className="text-md font-medium">
|
||||||
|
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
||||||
|
</span>
|
||||||
|
<span className="w-6 h-6">
|
||||||
|
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||||
|
|
||||||
|
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
||||||
|
<div className="max-w-screen-lg">
|
||||||
|
<span className="flex items-center mt-6">
|
||||||
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
|
Past Imports
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Session Id
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Imported
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Failed
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
|
||||||
|
? format(
|
||||||
|
new Date(parseInt(jobResult.earliestTimestamp)),
|
||||||
|
"EEEE, hh:mma, do LLLL y",
|
||||||
|
)
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{jobResult.sessionId}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
||||||
|
<span className="h-5 w-6">
|
||||||
|
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
<p className="whitespace-nowrap text-sm">
|
||||||
|
{jobResult.completedJobs}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
|
||||||
|
<span className="h-5 w-6">
|
||||||
|
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p className="whitespace-nowrap text-sm">
|
||||||
|
{jobResult.failedJobs}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Import;
|
||||||
379
src/client/components/Import/RealTimeImportStats.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { ReactElement, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useGetImportStatisticsQuery,
|
||||||
|
useGetWantedComicsQuery,
|
||||||
|
useStartIncrementalImportMutation,
|
||||||
|
} from "../../graphql/generated";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import statistics with card-based layout and progress bar.
|
||||||
|
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
||||||
|
* Also surfaces missing files detected by the file watcher.
|
||||||
|
*/
|
||||||
|
export const RealTimeImportStats = (): ReactElement => {
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
||||||
|
const [socketImport, setSocketImport] = useState<{
|
||||||
|
active: boolean;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
failed: number;
|
||||||
|
} | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
disconnectSocket: state.disconnectSocket,
|
||||||
|
importJobQueue: state.importJobQueue,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
||||||
|
{},
|
||||||
|
{ refetchOnWindowFocus: false, refetchInterval: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = importStats?.getImportStatistics?.stats;
|
||||||
|
|
||||||
|
// File list for the detail panel — only fetched when there are missing files
|
||||||
|
const { data: missingComicsData } = useGetWantedComicsQuery(
|
||||||
|
{
|
||||||
|
paginationOptions: { limit: 3, page: 1 },
|
||||||
|
predicate: { "importStatus.isRawFileMissing": true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: false,
|
||||||
|
enabled: (stats?.missingFiles ?? 0) > 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
||||||
|
|
||||||
|
const getMissingComicLabel = (comic: any): string => {
|
||||||
|
const series =
|
||||||
|
comic.canonicalMetadata?.series?.value ??
|
||||||
|
comic.inferredMetadata?.issue?.name;
|
||||||
|
const issueNum =
|
||||||
|
comic.canonicalMetadata?.issueNumber?.value ??
|
||||||
|
comic.inferredMetadata?.issue?.number;
|
||||||
|
if (series && issueNum) return `${series} #${issueNum}`;
|
||||||
|
if (series) return series;
|
||||||
|
return comic.rawFileDetails?.name ?? comic.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSession = useImportSessionStatus();
|
||||||
|
|
||||||
|
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
||||||
|
useStartIncrementalImportMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.startIncrementalImport.success) {
|
||||||
|
importJobQueue.setStatus("running");
|
||||||
|
setImportError(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setImportError(
|
||||||
|
error?.message || "Failed to start import. Please try again.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNewFiles = stats && stats.newFiles > 0;
|
||||||
|
const missingCount = stats?.missingFiles ?? 0;
|
||||||
|
|
||||||
|
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
||||||
|
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
||||||
|
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
|
||||||
|
const handleStatsChange = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDetected = (payload: { filePath: string }) => {
|
||||||
|
handleStatsChange();
|
||||||
|
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
||||||
|
setDetectedFile(name);
|
||||||
|
setTimeout(() => setDetectedFile(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportStarted = () => {
|
||||||
|
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverExtracted = (payload: {
|
||||||
|
completedJobCount: number;
|
||||||
|
totalJobCount: number;
|
||||||
|
importResult: unknown;
|
||||||
|
}) => {
|
||||||
|
setSocketImport((prev) => ({
|
||||||
|
active: true,
|
||||||
|
completed: payload.completedJobCount,
|
||||||
|
total: payload.totalJobCount,
|
||||||
|
failed: prev?.failed ?? 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverExtractionFailed = (payload: {
|
||||||
|
failedJobCount: number;
|
||||||
|
importResult: unknown;
|
||||||
|
}) => {
|
||||||
|
setSocketImport((prev) =>
|
||||||
|
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueueDrained = () => {
|
||||||
|
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
||||||
|
handleStatsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
||||||
|
socket.on("LS_FILES_MISSING", handleStatsChange);
|
||||||
|
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
||||||
|
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||||
|
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||||
|
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||||
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
||||||
|
socket.off("LS_FILES_MISSING", handleStatsChange);
|
||||||
|
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
||||||
|
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||||
|
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||||
|
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||||
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
};
|
||||||
|
}, [getSocket, queryClient]);
|
||||||
|
|
||||||
|
const handleStartImport = async () => {
|
||||||
|
setImportError(null);
|
||||||
|
|
||||||
|
if (importSession.isActive) {
|
||||||
|
setImportError(
|
||||||
|
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importJobQueue.status === "drained") {
|
||||||
|
localStorage.removeItem("sessionId");
|
||||||
|
disconnectSocket("/");
|
||||||
|
setTimeout(() => {
|
||||||
|
getSocket("/");
|
||||||
|
setTimeout(() => {
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
startIncrementalImport({ sessionId });
|
||||||
|
}, 500);
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
|
startIncrementalImport({ sessionId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !stats) {
|
||||||
|
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirstImport = stats.alreadyImported === 0;
|
||||||
|
const buttonText = isFirstImport
|
||||||
|
? `Start Import (${stats.newFiles} files)`
|
||||||
|
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||||
|
|
||||||
|
// Determine what to show in each card based on current phase
|
||||||
|
const sessionStats = importSession.stats;
|
||||||
|
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||||
|
|
||||||
|
const totalFiles = stats.totalLocalFiles;
|
||||||
|
const importedCount = stats.alreadyImported;
|
||||||
|
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||||
|
|
||||||
|
const showProgressBar = socketImport !== null;
|
||||||
|
const socketProgressPct =
|
||||||
|
socketImport && socketImport.total > 0
|
||||||
|
? Math.round((socketImport.completed / socketImport.total) * 100)
|
||||||
|
: 0;
|
||||||
|
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||||
|
const showMissingCard = missingCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Error Message */}
|
||||||
|
{importError && (
|
||||||
|
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
||||||
|
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-red-800 dark:text-red-300">
|
||||||
|
Import Error
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||||
|
{importError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setImportError(null)}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||||
|
>
|
||||||
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File detected toast */}
|
||||||
|
{detectedFile && (
|
||||||
|
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
||||||
|
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
|
||||||
|
New file detected: {detectedFile}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start Import button — only when idle with new files */}
|
||||||
|
{hasNewFiles && !importSession.isActive && (
|
||||||
|
<button
|
||||||
|
onClick={handleStartImport}
|
||||||
|
disabled={isStartingImport}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||||
|
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar — shown while importing and once complete */}
|
||||||
|
{showProgressBar && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{socketImport!.active
|
||||||
|
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
||||||
|
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{socketProgressPct}% complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
||||||
|
style={{ width: `${socketProgressPct}%` }}
|
||||||
|
>
|
||||||
|
{socketImport!.active && (
|
||||||
|
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{/* Total files */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{ backgroundColor: "#6b7280" }}
|
||||||
|
>
|
||||||
|
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
||||||
|
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Imported */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{ backgroundColor: "#d8dab2" }}
|
||||||
|
>
|
||||||
|
<div className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
|
{importedCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-700 font-medium">
|
||||||
|
{importSession.isActive ? "imported so far" : "imported in database"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Failed — only shown after a session with failures */}
|
||||||
|
{showFailedCard && (
|
||||||
|
<div className="rounded-lg p-6 text-center bg-red-500">
|
||||||
|
<div className="text-4xl font-bold text-white mb-2">
|
||||||
|
{failedCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-100 font-medium">failed</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Missing files — shown when watcher detects moved/deleted files */}
|
||||||
|
{showMissingCard && (
|
||||||
|
<div className="rounded-lg p-6 text-center bg-card-missing">
|
||||||
|
<div className="text-4xl font-bold text-slate-700 mb-2">
|
||||||
|
{missingCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-800 font-medium">missing</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing files detail panel */}
|
||||||
|
{showMissingCard && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||||
|
{missingCount} {missingCount === 1 ? "file" : "files"} missing
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||||
|
These files were previously imported but can no longer be found
|
||||||
|
on disk. Move them back to restore access.
|
||||||
|
</p>
|
||||||
|
{missingDocs.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{missingDocs.map((comic, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="text-xs text-amber-700 dark:text-amber-400 truncate"
|
||||||
|
>
|
||||||
|
{getMissingComicLabel(comic)} is missing
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{missingCount > 3 && (
|
||||||
|
<li className="text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
and {missingCount - 3} more.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/library?filter=missingFiles"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span className="underline">
|
||||||
|
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
|
||||||
|
View Missing Files In Library
|
||||||
|
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealTimeImportStats;
|
||||||
@@ -1,149 +1,194 @@
|
|||||||
import React, { useMemo, ReactElement, useCallback, useEffect } from "react";
|
import React, { useMemo, ReactElement, useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import SearchBar from "../Library/SearchBar";
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
|
import {
|
||||||
|
useQuery,
|
||||||
|
keepPreviousData,
|
||||||
|
useQueryClient,
|
||||||
|
} 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" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
* Library page component. Displays a paginated, searchable table of all comics
|
||||||
*
|
* in the collection, with an optional filter for comics with missing raw files.
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* <Library />
|
|
||||||
*/
|
*/
|
||||||
export const Library = (): ReactElement => {
|
export const Library = (): ReactElement => {
|
||||||
const searchResults = useSelector(
|
const [searchParams] = useSearchParams();
|
||||||
(state: RootState) => state.fileOps.libraryComics,
|
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
||||||
);
|
|
||||||
const searchError = useSelector((state: RootState) => {
|
|
||||||
console.log(state);
|
|
||||||
return state.fileOps.librarySearchError;
|
|
||||||
});
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(
|
|
||||||
searchIssue(
|
|
||||||
{
|
|
||||||
query: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pagination: {
|
|
||||||
size: 15,
|
|
||||||
from: 0,
|
|
||||||
},
|
|
||||||
type: "all",
|
|
||||||
trigger: "libraryPage",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// programatically navigate to comic detail
|
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
||||||
const navigate = useNavigate();
|
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
||||||
const navigateToComicDetail = (row) => {
|
query: {},
|
||||||
navigate(`/comic/details/${row.original._id}`);
|
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;
|
||||||
|
return await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: "http://localhost:3000/api/search/searchIssue",
|
||||||
|
data: { query, pagination, type },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComicInfoXML = (value) => {
|
const { data, isPlaceholderData } = useQuery({
|
||||||
return value.data ? (
|
queryKey: ["comics", searchQuery],
|
||||||
<div className="comicvine-metadata">
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
<dl>
|
placeholderData: keepPreviousData,
|
||||||
<span className="tags has-addons is-size-7">
|
enabled: activeFilter === "all",
|
||||||
<span className="tag">Series</span>
|
});
|
||||||
<span className="tag is-warning is-light">
|
|
||||||
{ellipsize(value.data.series[0], 25)}
|
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
||||||
|
{
|
||||||
|
paginationOptions: { limit: 25, page: 1 },
|
||||||
|
predicate: { "importStatus.isRawFileMissing": true },
|
||||||
|
},
|
||||||
|
{ enabled: activeFilter === "missingFiles" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: missingIdsData } = useGetWantedComicsQuery(
|
||||||
|
{
|
||||||
|
paginationOptions: { limit: 1000, page: 1 },
|
||||||
|
predicate: { "importStatus.isRawFileMissing": true },
|
||||||
|
},
|
||||||
|
{ enabled: activeFilter === "all" },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
||||||
|
const missingIdSet = useMemo(
|
||||||
|
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
||||||
|
[missingIdsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = data?.data;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
||||||
|
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
||||||
|
|
||||||
|
/** Triggers a search by volume name and resets pagination. */
|
||||||
|
const searchIssues = (e: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: { volumeName: e.search },
|
||||||
|
pagination: { size: 15, from: 0 },
|
||||||
|
type: "volumeName",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
|
||||||
|
{ellipsize(value.data.series[0], 25)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
||||||
|
Pages: {value.data.pagecount[0]}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</dl>
|
<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">
|
||||||
<dl>
|
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||||
<div className="control">
|
</span>
|
||||||
<span className="tags has-addons is-size-7 mt-2">
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
<span className="tag">Pages</span>
|
{!isNil(value.data.number) && (
|
||||||
<span className="tag is-info is-light has-text-weight-bold">
|
<span>{parseInt(value.data.number[0], 10)}</span>
|
||||||
{value.data.pagecount[0]}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
<div className="control">
|
|
||||||
<span className="tags has-addons is-size-7 mt-2">
|
|
||||||
<span className="tag">Issue</span>
|
|
||||||
{!isNil(value.data.number) && (
|
|
||||||
<span className="tag has-text-weight-bold is-success is-light">
|
|
||||||
{parseInt(value.data.number[0], 10)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
) : null;
|
) : null;
|
||||||
};
|
|
||||||
|
|
||||||
const WantedStatus = ({ value }) => {
|
const missingFilesColumns = useMemo(
|
||||||
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
|
|
||||||
};
|
|
||||||
const columns = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: "Comic Metadata",
|
header: "Missing Files",
|
||||||
footer: 1,
|
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "File Details",
|
header: "Status",
|
||||||
id: "fileDetails",
|
id: "missingStatus",
|
||||||
minWidth: 400,
|
cell: () => (
|
||||||
accessorKey: "_source",
|
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
|
||||||
cell: (info) => {
|
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
|
||||||
return <MetadataPanel data={info.getValue()} />;
|
<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: "ComicInfo.xml",
|
header: "Comic",
|
||||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
id: "missingComic",
|
||||||
align: "center",
|
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
cell: (info) =>
|
accessorFn: (row: any) => row,
|
||||||
!isEmpty(info.getValue()) ? (
|
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
|
||||||
<ComicInfoXML data={info.getValue()} />
|
|
||||||
) : (
|
|
||||||
<span className="tag mt-5">No ComicInfo.xml</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Additional Metadata",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Publisher",
|
|
||||||
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
|
||||||
cell: (info) => {
|
|
||||||
return (
|
|
||||||
!isNil(info.getValue()) && (
|
|
||||||
<h6 className="is-size-7 has-text-weight-bold">
|
|
||||||
{info.getValue().publisher.name}
|
|
||||||
</h6>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Something",
|
|
||||||
accessorKey: "_source.acquisition.source.wanted",
|
|
||||||
cell: (info) => {
|
|
||||||
!isUndefined(info.getValue()) ? (
|
|
||||||
<WantedStatus value={info.getValue().toString()} />
|
|
||||||
) : (
|
|
||||||
"Nothing"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -151,109 +196,163 @@ export const Library = (): ReactElement => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const columns = useMemo(
|
||||||
* Pagination control that fetches the next x (pageSize) items
|
() => [
|
||||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
{
|
||||||
* @param {number} pageIndex
|
header: "Comic Metadata",
|
||||||
* @param {number} pageSize
|
columns: [
|
||||||
* @returns void
|
{
|
||||||
*
|
header: "File Details",
|
||||||
**/
|
id: "fileDetails",
|
||||||
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
minWidth: 250,
|
||||||
dispatch(
|
accessorKey: "_source",
|
||||||
searchIssue(
|
cell: (info: any) => {
|
||||||
{
|
const source = info.getValue();
|
||||||
query: {},
|
return (
|
||||||
},
|
<MetadataPanel
|
||||||
{
|
data={source}
|
||||||
pagination: {
|
isMissing={missingIdSet.has(info.row.original._id)}
|
||||||
size: pageSize,
|
/>
|
||||||
from: pageSize * pageIndex + 1,
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "all",
|
{
|
||||||
trigger: "libraryPage",
|
header: "ComicInfo.xml",
|
||||||
},
|
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||||
),
|
cell: (info: any) =>
|
||||||
);
|
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = 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: "all",
|
],
|
||||||
trigger: "libraryPage",
|
},
|
||||||
},
|
{
|
||||||
),
|
header: "Additional Metadata",
|
||||||
);
|
columns: [
|
||||||
}, []);
|
{
|
||||||
|
header: "Date of Import",
|
||||||
// ImportStatus.propTypes = {
|
accessorKey: "_source.createdAt",
|
||||||
// value: PropTypes.bool.isRequired,
|
cell: (info: any) =>
|
||||||
// };
|
!isNil(info.getValue()) ? (
|
||||||
return (
|
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||||
<section className="container">
|
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
||||||
<div className="section">
|
{format(parseISO(info.getValue()), "h aaaa")}
|
||||||
<div className="header-area">
|
|
||||||
<h1 className="title">Library</h1>
|
|
||||||
</div>
|
|
||||||
{!isEmpty(searchResults) ? (
|
|
||||||
<div>
|
|
||||||
<div className="library">
|
|
||||||
<T2Table
|
|
||||||
totalPages={searchResults.hits.total.value}
|
|
||||||
columns={columns}
|
|
||||||
sourceData={searchResults?.hits?.hits}
|
|
||||||
rowClickHandler={navigateToComicDetail}
|
|
||||||
paginationHandlers={{
|
|
||||||
nextPage,
|
|
||||||
previousPage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column is-two-thirds">
|
|
||||||
<article className="message is-link">
|
|
||||||
<div className="message-body">
|
|
||||||
No comics were found in the library, Elasticsearch reports no
|
|
||||||
indices. Try importing a few comics into the library and come
|
|
||||||
back.
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
) : null,
|
||||||
<pre>
|
},
|
||||||
{!isUndefined(searchError.data) &&
|
{
|
||||||
JSON.stringify(
|
header: "Downloads",
|
||||||
searchError.data.meta.body.error.root_cause,
|
accessorKey: "_source.acquisition",
|
||||||
null,
|
cell: (info: any) => (
|
||||||
4,
|
<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">
|
||||||
</pre>
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
DC++: {info.getValue().directconnect.downloads.length}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
Torrent: {info.getValue().torrent.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[missingIdSet],
|
||||||
|
);
|
||||||
|
|
||||||
|
const FilterDropdown = () => (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={activeFilter}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
||||||
|
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMissingFilter = activeFilter === "missingFiles";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
|
Library
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse your comic book collection.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
|
{isMissingFilter ? (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
{isMissingLoading ? (
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<T2Table
|
||||||
|
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
|
||||||
|
columns={missingFilesColumns}
|
||||||
|
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
|
||||||
|
rowClickHandler={navigateToMissingComicDetail}
|
||||||
|
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
|
||||||
|
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
|
||||||
|
>
|
||||||
|
<FilterDropdown />
|
||||||
|
</T2Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : !isUndefined(searchResults?.hits) ? (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
<T2Table
|
||||||
|
totalPages={searchResults.hits.total.value}
|
||||||
|
columns={columns}
|
||||||
|
sourceData={searchResults?.hits.hits}
|
||||||
|
rowClickHandler={navigateToComicDetail}
|
||||||
|
getRowClassName={(row) =>
|
||||||
|
missingIdSet.has(row.original._id)
|
||||||
|
? "bg-card-missing/40 hover:bg-card-missing/20"
|
||||||
|
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
|
||||||
|
}
|
||||||
|
paginationHandlers={{ nextPage, previousPage }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FilterDropdown />
|
||||||
|
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
|
||||||
|
</div>
|
||||||
|
</T2Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-screen-xl mt-5">
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
No comics were found in the library, Elasticsearch reports no indices. Try
|
||||||
|
importing a few comics into the library and come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<FilterDropdown />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useDispatch, useSelector } from "react-redux";
|
|||||||
import { getComicBooks } from "../../actions/fileops.actions";
|
import { getComicBooks } from "../../actions/fileops.actions";
|
||||||
import { isNil, isEmpty, isUndefined } from "lodash";
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
import Masonry from "react-masonry-css";
|
import Masonry from "react-masonry-css";
|
||||||
import Card from "../Carda";
|
import Card from "../shared/Carda";
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
@@ -72,8 +72,11 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
|||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div className="content is-flex is-flex-direction-row">
|
||||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||||
<span className="icon cv-icon is-small">
|
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||||
<img src="/dist/img/cvlogo.svg" />
|
<img
|
||||||
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isNil(rawFileDetails) && (
|
{isNil(rawFileDetails) && (
|
||||||
|
|||||||
@@ -1,63 +1,47 @@
|
|||||||
import React, { ReactElement, useCallback } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
|
|
||||||
export const SearchBar = (): ReactElement => {
|
export const SearchBar = (props): ReactElement => {
|
||||||
const dispatch = useDispatch();
|
const { searchHandler } = props;
|
||||||
const handleSubmit = useCallback((e) => {
|
|
||||||
dispatch(
|
|
||||||
searchIssue(
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
volumeName: e.search,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pagination: {
|
|
||||||
size: 25,
|
|
||||||
from: 0,
|
|
||||||
},
|
|
||||||
type: "volumeName",
|
|
||||||
trigger: "libraryPage",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<div className="box">
|
<Form
|
||||||
<Form
|
onSubmit={searchHandler}
|
||||||
onSubmit={handleSubmit}
|
initialValues={{}}
|
||||||
initialValues={{}}
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
<form onSubmit={handleSubmit}>
|
||||||
<form onSubmit={handleSubmit}>
|
<Field name="search">
|
||||||
<div className="field is-grouped">
|
{({ input, meta }) => {
|
||||||
<div className="control search is-expanded">
|
return (
|
||||||
<Field name="search">
|
<div className="flex flex-row w-full">
|
||||||
{({ input, meta }) => {
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
|
||||||
return (
|
<div className="w-10 text-gray-400">
|
||||||
<input
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
{...input}
|
</div>
|
||||||
className="input main-search-bar is-medium"
|
|
||||||
placeholder="Type an issue/volume name"
|
<input
|
||||||
/>
|
{...input}
|
||||||
);
|
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
|
||||||
}}
|
type="text"
|
||||||
</Field>
|
id="search"
|
||||||
</div>
|
placeholder="Type an issue/volume name"
|
||||||
<div className="control">
|
/>
|
||||||
<button className="button is-medium" type="submit">
|
</div>
|
||||||
Search
|
|
||||||
</button>
|
<button
|
||||||
</div>
|
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"
|
||||||
</div>
|
type="submit"
|
||||||
</form>
|
>
|
||||||
)}
|
Search
|
||||||
/>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { isNil, map } from "lodash";
|
|
||||||
import { applyComicVineMatch } from "../actions/comicinfo.actions";
|
|
||||||
import { convert } from "html-to-text";
|
|
||||||
import ellipsize from "ellipsize";
|
|
||||||
|
|
||||||
interface MatchResultProps {
|
|
||||||
matchData: any;
|
|
||||||
comicObjectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBrokenImage = (e) => {
|
|
||||||
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MatchResult = (props: MatchResultProps) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const applyCVMatch = useCallback(
|
|
||||||
(match, comicObjectId) => {
|
|
||||||
dispatch(applyComicVineMatch(match, comicObjectId));
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{map(props.matchData, (match, idx) => {
|
|
||||||
let issueDescription = "";
|
|
||||||
if (!isNil(match.description)) {
|
|
||||||
issueDescription = convert(match.description, {
|
|
||||||
baseElements: {
|
|
||||||
selectors: ["p"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="search-result mb-4" key={idx}>
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column is-one-fifth">
|
|
||||||
<img
|
|
||||||
className="cover-image"
|
|
||||||
src={match.image.thumb_url}
|
|
||||||
onError={handleBrokenImage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<div className="is-size-7">
|
|
||||||
{ellipsize(issueDescription, 300)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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="cover-image"
|
|
||||||
onError={handleBrokenImage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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="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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MatchResult;
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import React, { useContext } from "react";
|
|
||||||
import { SearchBar } from "./GlobalSearchBar/SearchBar";
|
|
||||||
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { isUndefined } from "lodash";
|
|
||||||
import { format, fromUnixTime } from "date-fns";
|
|
||||||
|
|
||||||
const Navbar: React.FunctionComponent = (props) => {
|
|
||||||
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="/" className="navbar-item">
|
|
||||||
<img
|
|
||||||
src="/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>
|
|
||||||
{downloadProgressTick && <div className="pulsating-circle"></div>}
|
|
||||||
</a>
|
|
||||||
{!isUndefined(downloadProgressTick) ? (
|
|
||||||
<div className="navbar-dropdown is-right">
|
|
||||||
<a className="navbar-item">
|
|
||||||
<DownloadProgressTick data={downloadProgressTick} />
|
|
||||||
</a> </div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{/* AirDC++ socket connection status */}
|
|
||||||
<div className="navbar-item has-dropdown is-hoverable">
|
|
||||||
{airDCPPSocketConnectionStatus ? (
|
|
||||||
<>
|
|
||||||
<a className="navbar-link is-arrowless has-text-success">
|
|
||||||
<i className="fa-solid fa-bolt"></i>
|
|
||||||
</a>
|
|
||||||
<div className="navbar-dropdown pt-4 pr-2 pl-2 is-right airdcpp-status">
|
|
||||||
{/* AirDC++ Session Information */}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Last login was{" "}
|
|
||||||
<span className="tag">
|
|
||||||
{format(
|
|
||||||
fromUnixTime(airDCPPSessionInfo.user.last_login),
|
|
||||||
"dd MMMM, yyyy",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<hr className="navbar-divider" />
|
|
||||||
<p>
|
|
||||||
<span className="tag has-text-success">
|
|
||||||
{airDCPPSessionInfo.user.username}
|
|
||||||
</span>
|
|
||||||
connected to{" "}
|
|
||||||
<span className="tag has-text-success">
|
|
||||||
{airDCPPSessionInfo.system_info.client_version}
|
|
||||||
</span>{" "}
|
|
||||||
with session ID{" "}
|
|
||||||
<span className="tag has-text-success">
|
|
||||||
{airDCPPSessionInfo.session_id}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
|
|
||||||
</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">
|
|
||||||
<pre>
|
|
||||||
{JSON.stringify(socketDisconnectionReason, 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;
|
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import Card from "../shared/Carda";
|
||||||
import Card from "../Carda";
|
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
export const PullList = (): ReactElement => {
|
export const PullList = (): ReactElement => {
|
||||||
const pullListComics = useSelector(
|
// const pullListComics = useSelector(
|
||||||
(state: RootState) => state.comicInfo.pullList,
|
// (state: RootState) => state.comicInfo.pullList,
|
||||||
);
|
// );
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(
|
// dispatch(
|
||||||
getWeeklyPullList({
|
// getWeeklyPullList({
|
||||||
startDate: "2022-11-15",
|
// startDate: "2023-7-28",
|
||||||
pageSize: "15",
|
// pageSize: "15",
|
||||||
currentPage: "1",
|
// currentPage: "1",
|
||||||
}),
|
// }),
|
||||||
);
|
// );
|
||||||
}, []);
|
}, []);
|
||||||
const nextPageHandler = () => {};
|
const nextPageHandler = () => {};
|
||||||
const previousPageHandler = () => {};
|
const previousPageHandler = () => {};
|
||||||
@@ -101,7 +99,7 @@ export const PullList = (): ReactElement => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="container">
|
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="header-area">
|
<div className="header-area">
|
||||||
<h1 className="title">Weekly Pull List</h1>
|
<h1 className="title">Weekly Pull List</h1>
|
||||||
@@ -109,15 +107,15 @@ export const PullList = (): ReactElement => {
|
|||||||
{!isNil(pullListComics) && (
|
{!isNil(pullListComics) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<T2Table
|
<T2Table
|
||||||
sourceData={pullListComics}
|
sourceData={pullListComics}
|
||||||
columns={columnData}
|
columns={columnData}
|
||||||
totalPages={pullListComics.length}
|
totalPages={pullListComics.length}
|
||||||
paginationHandlers={{
|
paginationHandlers={{
|
||||||
nextPage: nextPageHandler,
|
nextPage: nextPageHandler,
|
||||||
previousPage: previousPageHandler,
|
previousPage: previousPageHandler,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import React, { useMemo, useCallback, ReactElement } from "react";
|
|
||||||
import { isNil, isEmpty } from "lodash";
|
|
||||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
|
||||||
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 "./Carda";
|
|
||||||
import ellipsize from "ellipsize";
|
|
||||||
import { convert } from "html-to-text";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
interface ISearchProps {}
|
|
||||||
|
|
||||||
export const Search = ({}: ISearchProps): ReactElement => {
|
|
||||||
const formData = {
|
|
||||||
search: "",
|
|
||||||
};
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="container">
|
|
||||||
<div className="section search">
|
|
||||||
<h1 className="title">Search</h1>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
onSubmit={getCVSearchResults}
|
|
||||||
initialValues={{
|
|
||||||
...formData,
|
|
||||||
}}
|
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!isNil(comicVineSearchResults.results) &&
|
|
||||||
!isEmpty(comicVineSearchResults.results) ? (
|
|
||||||
<div className="columns is-multiline">
|
|
||||||
{comicVineSearchResults.results.map((result) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={result.id}
|
|
||||||
className="comicvine-metadata column is-10 mb-3"
|
|
||||||
>
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column is-one-quarter">
|
|
||||||
<Card
|
|
||||||
key={result.id}
|
|
||||||
orientation={"vertical"}
|
|
||||||
imageUrl={result.image.small_url}
|
|
||||||
title={result.name}
|
|
||||||
hasDetails={false}
|
|
||||||
></Card>
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<div className="is-size-3">
|
|
||||||
{!isEmpty(result.name) ? (
|
|
||||||
result.name
|
|
||||||
) : (
|
|
||||||
<span className="is-size-3">No Name</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="field is-grouped mt-1">
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag is-light">Cover date</span>
|
|
||||||
<span className="tag is-info is-light">
|
|
||||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag is-warning">
|
|
||||||
{result.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href={result.api_detail_url}>
|
|
||||||
{result.api_detail_url}
|
|
||||||
</a>
|
|
||||||
<p>
|
|
||||||
{ellipsize(
|
|
||||||
convert(result.description, {
|
|
||||||
baseElements: {
|
|
||||||
selectors: ["p"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
320,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Search;
|
|
||||||
471
src/client/components/Search/Search.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import React, { ReactElement, useState } from "react";
|
||||||
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
|
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import Card from "../shared/Carda";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "../../shared/utils/i18n.util";
|
||||||
|
import PopoverButton from "../shared/PopoverButton";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate and refetch wanted comics queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToLibrary = (sourceName: string, comicData) =>
|
||||||
|
setComicVineMetadata({ sourceName, comicData });
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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" && (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||||
|
<Card
|
||||||
|
key={result.id}
|
||||||
|
orientation={"cover-only"}
|
||||||
|
imageUrl={result.image.small_url}
|
||||||
|
hasDetails={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-3/4">
|
||||||
|
<div className="text-xl">
|
||||||
|
{!isEmpty(result.name) ? (
|
||||||
|
result.name
|
||||||
|
) : (
|
||||||
|
<span className="text-xl">No Name</span>
|
||||||
|
)}
|
||||||
|
{result.start_year && <> ({result.start_year})</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{/* issue count */}
|
||||||
|
{result.count_of_issues && (
|
||||||
|
<div className="my-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{t("issueWithCount", {
|
||||||
|
count: result.count_of_issues,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* type: TPB, one-shot, graphic novel etc. */}
|
||||||
|
{!isNil(result.description) &&
|
||||||
|
!isUndefined(result.description) && (
|
||||||
|
<>
|
||||||
|
{!isEmpty(
|
||||||
|
detectIssueTypes(result.description),
|
||||||
|
) && (
|
||||||
|
<div className="my-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{
|
||||||
|
detectIssueTypes(result.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="tag is-warning">{result.id}</span>
|
||||||
|
<p>
|
||||||
|
<a href={result.api_detail_url}>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<p className="text-sm">
|
||||||
|
{ellipsize(
|
||||||
|
convert(result.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p", "div"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
320,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<PopoverButton
|
||||||
|
content={`Adding this volume will add ${t(
|
||||||
|
"issueWithCount",
|
||||||
|
{
|
||||||
|
count: result.count_of_issues,
|
||||||
|
},
|
||||||
|
)} to your wanted list.`}
|
||||||
|
clickHandler={() =>
|
||||||
|
addToWantedList({
|
||||||
|
source: "comicvine",
|
||||||
|
comicObject: result,
|
||||||
|
markEntireVolumeWanted: true,
|
||||||
|
resourceType: "volume",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
||||||
25
src/client/components/ServiceStatuses/ServiceStatuses.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getServiceStatus } from "../../actions/fileops.actions";
|
||||||
|
|
||||||
|
export const ServiceStatuses = (): ReactElement => {
|
||||||
|
const serviceStatus = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.libraryServiceStatus,
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getServiceStatus());
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="is-clearfix">
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="title">Core Services</h3>
|
||||||
|
<h6 className="subtitle has-text-grey-light">
|
||||||
|
Statuses for core services
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify(serviceStatus, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||