refactor: split CSS and JS into modular files (SRP)
This commit is contained in:
parent
94cf36bff3
commit
2e5731b5d6
@ -101,6 +101,94 @@ src/server/
|
||||
|
||||
Source: [src/server/](../src/server/)
|
||||
|
||||
### 2.2.1 Frontend Architecture (`src/server/web/static/`)
|
||||
|
||||
The frontend uses a modular architecture with no build step required. CSS and JavaScript files are organized by responsibility.
|
||||
|
||||
#### CSS Structure
|
||||
|
||||
```
|
||||
src/server/web/static/css/
|
||||
+-- styles.css # Entry point with @import statements
|
||||
+-- base/
|
||||
| +-- variables.css # CSS custom properties (colors, fonts, spacing)
|
||||
| +-- reset.css # CSS reset and normalize styles
|
||||
| +-- typography.css # Font styles, headings, text utilities
|
||||
+-- components/
|
||||
| +-- buttons.css # All button styles
|
||||
| +-- cards.css # Card and panel components
|
||||
| +-- forms.css # Form inputs, labels, validation styles
|
||||
| +-- modals.css # Modal and overlay styles
|
||||
| +-- navigation.css # Header, nav, sidebar styles
|
||||
| +-- progress.css # Progress bars, loading indicators
|
||||
| +-- notifications.css # Toast, alerts, messages
|
||||
| +-- tables.css # Table and list styles
|
||||
| +-- status.css # Status badges and indicators
|
||||
+-- pages/
|
||||
| +-- login.css # Login page specific styles
|
||||
| +-- index.css # Index/library page specific styles
|
||||
| +-- queue.css # Queue page specific styles
|
||||
+-- utilities/
|
||||
+-- animations.css # Keyframes and animation classes
|
||||
+-- responsive.css # Media queries and breakpoints
|
||||
+-- helpers.css # Utility classes (hidden, flex, spacing)
|
||||
```
|
||||
|
||||
#### JavaScript Structure
|
||||
|
||||
JavaScript uses the IIFE pattern with a shared `AniWorld` namespace for browser compatibility without build tools.
|
||||
|
||||
```
|
||||
src/server/web/static/js/
|
||||
+-- shared/ # Shared utilities used by all pages
|
||||
| +-- constants.js # API endpoints, localStorage keys, defaults
|
||||
| +-- auth.js # Token management (getToken, setToken, checkAuth)
|
||||
| +-- api-client.js # Fetch wrapper with auto-auth headers
|
||||
| +-- theme.js # Dark/light theme toggle
|
||||
| +-- ui-utils.js # Toast notifications, format helpers
|
||||
| +-- websocket-client.js # Socket.IO wrapper
|
||||
+-- index/ # Index page modules
|
||||
| +-- series-manager.js # Series list rendering and filtering
|
||||
| +-- selection-manager.js# Multi-select and bulk download
|
||||
| +-- search.js # Series search functionality
|
||||
| +-- scan-manager.js # Library rescan operations
|
||||
| +-- scheduler-config.js # Scheduler configuration
|
||||
| +-- logging-config.js # Logging configuration
|
||||
| +-- advanced-config.js # Advanced settings
|
||||
| +-- main-config.js # Main configuration and backup
|
||||
| +-- config-manager.js # Config modal orchestrator
|
||||
| +-- socket-handler.js # WebSocket event handlers
|
||||
| +-- app-init.js # Application initialization
|
||||
+-- queue/ # Queue page modules
|
||||
+-- queue-api.js # Queue API interactions
|
||||
+-- queue-renderer.js # Queue list rendering
|
||||
+-- progress-handler.js # Download progress updates
|
||||
+-- queue-socket-handler.js # WebSocket events for queue
|
||||
+-- queue-init.js # Queue page initialization
|
||||
```
|
||||
|
||||
#### Module Pattern
|
||||
|
||||
All JavaScript modules follow the IIFE pattern with namespace:
|
||||
|
||||
```javascript
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ModuleName = (function () {
|
||||
"use strict";
|
||||
|
||||
// Private variables and functions
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
publicMethod: publicMethod,
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
Source: [src/server/web/static/](../src/server/web/static/)
|
||||
|
||||
### 2.3 Core Layer (`src/core/`)
|
||||
|
||||
Domain logic for anime series management.
|
||||
|
||||
@ -106,149 +106,785 @@ For each task completed:
|
||||
|
||||
---
|
||||
|
||||
## Task: Refactor CSS & JavaScript Files (Single Responsibility Principle) ✅ COMPLETED
|
||||
|
||||
### Status: COMPLETED
|
||||
|
||||
The CSS and JavaScript files have been successfully refactored into modular structures.
|
||||
|
||||
### Summary of Changes
|
||||
|
||||
**CSS Refactoring:**
|
||||
|
||||
- Created 17 modular CSS files organized into `base/`, `components/`, `pages/`, and `utilities/` directories
|
||||
- `styles.css` now serves as an entry point with @import statements
|
||||
- All CSS files under 500 lines (largest: helpers.css at 368 lines)
|
||||
- Total: 3,146 lines across 17 files
|
||||
|
||||
**JavaScript Refactoring:**
|
||||
|
||||
- Created 6 shared utility modules in `js/shared/`
|
||||
- Created 11 index page modules in `js/index/`
|
||||
- Created 5 queue page modules in `js/queue/`
|
||||
- Uses IIFE pattern with `AniWorld` namespace for browser compatibility
|
||||
- All JS files under 500 lines (largest: scan-manager.js at 439 lines)
|
||||
- Total: 4,795 lines across 22 modules
|
||||
|
||||
**Updated Files:**
|
||||
|
||||
- `index.html` - Updated script tags for modular JS
|
||||
- `queue.html` - Updated script tags for modular JS
|
||||
- `test_static_files.py` - Updated tests for modular architecture
|
||||
- `test_template_integration.py` - Updated tests for new JS structure
|
||||
- `ARCHITECTURE.md` - Added frontend architecture documentation
|
||||
|
||||
**Old Files (kept for reference):**
|
||||
|
||||
- `app.js` - Original monolithic file (can be deleted)
|
||||
- `queue.js` - Original monolithic file (can be deleted)
|
||||
|
||||
### Original Overview
|
||||
|
||||
Split monolithic `styles.css` (~2,135 lines), `app.js` (~2,305 lines), and `queue.js` (~993 lines) into smaller, focused files following the Single Responsibility Principle. Maximum 500 lines per file. All changes must maintain full backward compatibility with existing templates.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Server is running: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload`
|
||||
2. Password: `Hallo123!`
|
||||
3. Login via browser at `http://127.0.0.1:8000/login`
|
||||
|
||||
### Notes
|
||||
|
||||
- This is a simplification that removes complexity while maintaining core functionality
|
||||
- Improves user experience with explicit manual control
|
||||
- Easier to understand, test, and maintain
|
||||
- Good foundation for future enhancements if needed
|
||||
- Server is running and functional before starting
|
||||
- All existing functionality works (login, index, queue pages)
|
||||
- Backup current files before making changes
|
||||
|
||||
---
|
||||
|
||||
## Task: Enhanced Anime Add Flow ✅ COMPLETED
|
||||
### Task 1: Analyze Current File Structure
|
||||
|
||||
### Overview
|
||||
**Objective**: Understand the current codebase before making changes.
|
||||
|
||||
Enhance the anime addition workflow to automatically persist anime to the database, scan for missing episodes immediately, and create folders using the anime display name instead of the internal key.
|
||||
**Steps**:
|
||||
|
||||
### Requirements
|
||||
1. Open and read `src/server/web/static/css/styles.css`
|
||||
2. Open and read `src/server/web/static/js/app.js`
|
||||
3. Open and read `src/server/web/static/js/queue.js`
|
||||
4. Open and read `src/server/web/templates/index.html`
|
||||
5. Open and read `src/server/web/templates/queue.html`
|
||||
6. Open and read `src/server/web/templates/login.html`
|
||||
7. Document all CSS sections (look for comment headers)
|
||||
8. Document all JavaScript functions and their dependencies
|
||||
9. Identify shared utilities vs page-specific code
|
||||
|
||||
1. **After anime add → Save to database**: Ensure the anime is persisted to the database via `AnimeDBService.create_series()` immediately after validation
|
||||
2. **After anime add → Scan for missing episodes**: Trigger a targeted episode scan for only the newly added anime (not the entire library)
|
||||
3. **After anime add → Create folder with anime name**: Use the anime display name (sanitized) for the folder, not the anime key
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### Step 1: Examine Current Implementation
|
||||
|
||||
1. Open and read `src/server/routes/anime_routes.py` - find the `add_series` endpoint
|
||||
2. Open and read `src/core/SerieScanner.py` - understand how scanning works
|
||||
3. Open and read `src/core/entities/Serie.py` and `src/core/entities/SerieList.py` - understand folder handling
|
||||
4. Open and read `src/database/services/anime_db_service.py` - understand database operations
|
||||
5. Open and read `src/core/providers/AniWorldProvider.py` - understand how folders are created
|
||||
|
||||
#### Step 2: Create Utility Function for Folder Name Sanitization
|
||||
|
||||
1. Create or update utility module at `src/utils/filesystem.py`
|
||||
2. Implement `sanitize_folder_name(name: str) -> str` function that:
|
||||
- Removes/replaces characters invalid for filesystems: `< > : " / \ | ? *`
|
||||
- Trims leading/trailing whitespace and dots
|
||||
- Handles edge cases (empty string, only invalid chars)
|
||||
- Preserves Unicode characters (for Japanese titles, etc.)
|
||||
|
||||
#### Step 3: Update Serie Entity
|
||||
|
||||
1. Open `src/core/entities/Serie.py`
|
||||
2. Add a `folder` property that returns sanitized display name instead of key
|
||||
3. Ensure backward compatibility with existing series
|
||||
|
||||
#### Step 4: Update SerieList to Use Display Name for Folders
|
||||
|
||||
1. Open `src/core/entities/SerieList.py`
|
||||
2. In the `add()` method, use `serie.folder` (display name) instead of `serie.key` when creating directories
|
||||
3. Ensure the folder path is correctly stored in the Serie object
|
||||
|
||||
#### Step 5: Add Targeted Episode Scan Method to SerieScanner
|
||||
|
||||
1. Open `src/core/SerieScanner.py`
|
||||
2. Add new method `scan_single_series(self, key: str) -> List[Episode]`:
|
||||
- Fetches the specific anime from database/SerieList by key
|
||||
- Calls the provider to get available episodes
|
||||
- Compares with local files to find missing episodes
|
||||
- Returns list of missing episodes
|
||||
- Does NOT trigger a full library rescan
|
||||
|
||||
#### Step 6: Update add_series Endpoint
|
||||
|
||||
1. Open `src/server/routes/anime_routes.py`
|
||||
2. Modify the `add_series` endpoint to:
|
||||
- **Step A**: Validate the request (existing)
|
||||
- **Step B**: Create Serie object with sanitized folder name
|
||||
- **Step C**: Save to database via `AnimeDBService.create_series()`
|
||||
- **Step D**: Add to SerieList (which creates the folder)
|
||||
- **Step E**: Call `SerieScanner.scan_single_series(key)` for targeted scan
|
||||
- **Step F**: Return response including:
|
||||
- Success status
|
||||
- Created folder path
|
||||
- List of missing episodes found (if any)
|
||||
|
||||
#### Step 7: Update Provider Folder Handling
|
||||
|
||||
1. Open `src/core/providers/AniWorldProvider.py`
|
||||
2. Ensure download operations use `serie.folder` for filesystem paths
|
||||
3. If `EnhancedProvider.py` exists, update it similarly
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] When adding a new anime, it is immediately saved to the database
|
||||
- [x] When adding a new anime, only that anime is scanned for missing episodes (not full library)
|
||||
- [x] Folder is created using the sanitized display name (e.g., "Attack on Titan" not "attack-on-titan")
|
||||
- [x] Special characters in anime names are properly handled (`:`, `?`, etc.)
|
||||
- [x] Existing anime entries continue to work (backward compatibility)
|
||||
- [x] API response includes the created folder path and missing episodes count
|
||||
- [x] Unit tests cover the new functionality
|
||||
- [x] No regressions in existing tests
|
||||
|
||||
### Implementation Summary (Completed)
|
||||
|
||||
**Files Created/Modified:**
|
||||
|
||||
- `src/server/utils/filesystem.py` - New file with `sanitize_folder_name()`, `is_safe_path()`, `create_safe_folder()`
|
||||
- `src/core/entities/series.py` - Added `sanitized_folder` property
|
||||
- `src/core/entities/SerieList.py` - Updated `add()` to use sanitized folder names
|
||||
- `src/core/SerieScanner.py` - Added `scan_single_series()` method
|
||||
- `src/server/api/anime.py` - Enhanced `add_series` endpoint with full flow
|
||||
|
||||
**Tests Added:**
|
||||
|
||||
- `tests/unit/test_filesystem_utils.py` - 43 tests for filesystem utilities
|
||||
- `tests/unit/test_serie_class.py` - 6 tests for `sanitized_folder` property
|
||||
- `tests/unit/test_serie_scanner.py` - 9 tests for `scan_single_series()`
|
||||
- `tests/api/test_anime_endpoints.py` - 6 integration tests for enhanced add flow
|
||||
|
||||
**All 97 related tests passing. No regressions in existing 848 unit tests and 60 API tests.**
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
1. **Unit Tests**:
|
||||
|
||||
- Test `sanitize_folder_name()` with various inputs (special chars, Unicode, edge cases)
|
||||
- Test `Serie.folder` property returns sanitized name
|
||||
- Test `SerieScanner.scan_single_series()` only scans the specified anime
|
||||
- Test database persistence on anime add
|
||||
|
||||
2. **Integration Tests**:
|
||||
- Test full add flow: request → database → folder creation → scan
|
||||
- Test that folder is created with correct name
|
||||
- Test API response contains expected fields
|
||||
|
||||
### Error Handling
|
||||
|
||||
- If database save fails, return appropriate error and don't create folder
|
||||
- If folder creation fails (permissions, disk full), return error and rollback database entry
|
||||
- If scan fails, still return success for add but indicate scan failure in response
|
||||
- Log all operations with appropriate log levels
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- Sanitize folder names to prevent path traversal attacks
|
||||
- Validate anime name length to prevent filesystem issues
|
||||
- Ensure folder is created within the configured library path only
|
||||
**Deliverable**: A mental map of all functions, styles, and their relationships.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create CSS Directory Structure
|
||||
|
||||
**Objective**: Set up the new CSS file organization.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. Create directory: `src/server/web/static/css/base/`
|
||||
2. Create directory: `src/server/web/static/css/components/`
|
||||
3. Create directory: `src/server/web/static/css/pages/`
|
||||
4. Create directory: `src/server/web/static/css/utilities/`
|
||||
|
||||
**File Structure to Create**:
|
||||
|
||||
```
|
||||
src/server/web/static/css/
|
||||
├── styles.css # Main entry point with @import statements
|
||||
├── base/
|
||||
│ ├── variables.css # CSS custom properties (colors, fonts, spacing)
|
||||
│ ├── reset.css # CSS reset and normalize styles
|
||||
│ └── typography.css # Font styles, headings, text utilities
|
||||
├── components/
|
||||
│ ├── buttons.css # All button styles
|
||||
│ ├── cards.css # Card and panel components
|
||||
│ ├── forms.css # Form inputs, labels, validation styles
|
||||
│ ├── modals.css # Modal and overlay styles
|
||||
│ ├── navigation.css # Header, nav, sidebar styles
|
||||
│ ├── progress.css # Progress bars, loading indicators
|
||||
│ ├── notifications.css # Toast, alerts, messages
|
||||
│ └── tables.css # Table and list styles
|
||||
├── pages/
|
||||
│ ├── login.css # Login page specific styles
|
||||
│ ├── index.css # Index/library page specific styles
|
||||
│ └── queue.css # Queue page specific styles
|
||||
└── utilities/
|
||||
├── animations.css # Keyframes and animation classes
|
||||
├── responsive.css # Media queries and breakpoints
|
||||
└── helpers.css # Utility classes (hidden, flex, spacing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Split styles.css into Modular Files
|
||||
|
||||
**Objective**: Extract styles from `styles.css` into appropriate module files.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Extract variables.css**:
|
||||
|
||||
- Find all `:root` CSS custom properties
|
||||
- Extract color variables, font variables, spacing variables
|
||||
- Include dark mode variables (`.dark-mode` or `[data-theme="dark"]`)
|
||||
|
||||
2. **Extract reset.css**:
|
||||
|
||||
- Extract `*`, `body`, `html` base resets
|
||||
- Extract box-sizing rules
|
||||
- Extract default margin/padding resets
|
||||
|
||||
3. **Extract typography.css**:
|
||||
|
||||
- Extract `h1-h6` styles
|
||||
- Extract paragraph, link, text styles
|
||||
- Extract font-related utility classes
|
||||
|
||||
4. **Extract buttons.css**:
|
||||
|
||||
- Find all `.btn`, `button`, `.button` related styles
|
||||
- Include hover, active, disabled states
|
||||
- Include button variants (primary, secondary, danger, etc.)
|
||||
|
||||
5. **Extract cards.css**:
|
||||
|
||||
- Extract `.card`, `.panel`, `.box` related styles
|
||||
- Include card headers, bodies, footers
|
||||
|
||||
6. **Extract forms.css**:
|
||||
|
||||
- Extract `input`, `select`, `textarea` styles
|
||||
- Extract `.form-group`, `.form-control` styles
|
||||
- Extract validation states (error, success)
|
||||
|
||||
7. **Extract modals.css**:
|
||||
|
||||
- Extract `.modal`, `.overlay`, `.dialog` styles
|
||||
- Include backdrop styles
|
||||
- Include modal animations
|
||||
|
||||
8. **Extract navigation.css**:
|
||||
|
||||
- Extract `header`, `nav`, `.navbar` styles
|
||||
- Extract menu and navigation link styles
|
||||
|
||||
9. **Extract progress.css**:
|
||||
|
||||
- Extract `.progress`, `.progress-bar` styles
|
||||
- Extract loading spinners and indicators
|
||||
|
||||
10. **Extract notifications.css**:
|
||||
|
||||
- Extract `.toast`, `.alert`, `.notification` styles
|
||||
- Include success, error, warning, info variants
|
||||
|
||||
11. **Extract tables.css**:
|
||||
|
||||
- Extract `table`, `.table` styles
|
||||
- Extract list styles if table-like
|
||||
|
||||
12. **Extract page-specific styles**:
|
||||
|
||||
- `login.css`: Styles only used on login page
|
||||
- `index.css`: Styles only used on index/library page (series cards, search)
|
||||
- `queue.css`: Styles only used on queue page (queue items, download status)
|
||||
|
||||
13. **Extract animations.css**:
|
||||
|
||||
- Extract all `@keyframes` rules
|
||||
- Extract animation utility classes
|
||||
|
||||
14. **Extract responsive.css**:
|
||||
|
||||
- Extract all `@media` queries
|
||||
- Organize by breakpoint
|
||||
|
||||
15. **Extract helpers.css**:
|
||||
|
||||
- Extract utility classes (.hidden, .flex, .text-center, etc.)
|
||||
- Extract spacing utilities
|
||||
|
||||
16. **Update main styles.css**:
|
||||
- Replace all content with `@import` statements
|
||||
- Order imports correctly (variables first, then reset, then components)
|
||||
|
||||
**Import Order in styles.css**:
|
||||
|
||||
```css
|
||||
/* Base */
|
||||
@import "base/variables.css";
|
||||
@import "base/reset.css";
|
||||
@import "base/typography.css";
|
||||
|
||||
/* Components */
|
||||
@import "components/buttons.css";
|
||||
@import "components/cards.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/modals.css";
|
||||
@import "components/navigation.css";
|
||||
@import "components/progress.css";
|
||||
@import "components/notifications.css";
|
||||
@import "components/tables.css";
|
||||
|
||||
/* Pages */
|
||||
@import "pages/login.css";
|
||||
@import "pages/index.css";
|
||||
@import "pages/queue.css";
|
||||
|
||||
/* Utilities (load last to allow overrides) */
|
||||
@import "utilities/animations.css";
|
||||
@import "utilities/responsive.css";
|
||||
@import "utilities/helpers.css";
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
|
||||
- Start the server
|
||||
- Check login page styling
|
||||
- Check index page styling
|
||||
- Check queue page styling
|
||||
- Verify dark mode toggle works
|
||||
- Verify responsive design works
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create JavaScript Directory Structure
|
||||
|
||||
**Objective**: Set up the new JavaScript file organization.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. Create directory: `src/server/web/static/js/shared/`
|
||||
2. Create directory: `src/server/web/static/js/index/`
|
||||
3. Create directory: `src/server/web/static/js/queue/`
|
||||
|
||||
**File Structure to Create**:
|
||||
|
||||
```
|
||||
src/server/web/static/js/
|
||||
├── app.js # Main entry point for index page
|
||||
├── queue.js # Main entry point for queue page
|
||||
├── shared/
|
||||
│ ├── auth.js # Authentication utilities
|
||||
│ ├── api-client.js # HTTP request wrapper with auth
|
||||
│ ├── websocket-client.js # WebSocket connection management
|
||||
│ ├── theme.js # Dark/light mode management
|
||||
│ ├── ui-utils.js # Toast, loading overlay, formatters
|
||||
│ └── constants.js # Shared constants and config
|
||||
├── index/
|
||||
│ ├── series-manager.js # Series loading, filtering, rendering
|
||||
│ ├── search.js # Search functionality
|
||||
│ ├── scan-manager.js # Library scan operations
|
||||
│ ├── config-manager.js # Configuration modal handling
|
||||
│ └── selection.js # Series/episode selection logic
|
||||
└── queue/
|
||||
├── queue-api.js # Queue API operations
|
||||
├── queue-renderer.js # Render queue items (pending, active, etc.)
|
||||
└── progress-handler.js # Real-time progress updates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extract Shared JavaScript Utilities
|
||||
|
||||
**Objective**: Create reusable utility modules used by both index and queue pages.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Create constants.js**:
|
||||
|
||||
- Extract API endpoint URLs
|
||||
- Extract localStorage keys
|
||||
- Extract any magic strings or numbers
|
||||
|
||||
2. **Create auth.js**:
|
||||
|
||||
- Extract `checkAuth()` function
|
||||
- Extract `logout()` function
|
||||
- Extract `getAuthHeaders()` or token retrieval logic
|
||||
- Extract token storage/retrieval from localStorage
|
||||
|
||||
3. **Create api-client.js**:
|
||||
|
||||
- Extract `fetchWithAuth()` wrapper function
|
||||
- Handle automatic token injection
|
||||
- Handle 401 redirect to login
|
||||
- Handle common error responses
|
||||
|
||||
4. **Create websocket-client.js**:
|
||||
|
||||
- Extract WebSocket connection setup
|
||||
- Extract message handling dispatcher
|
||||
- Extract reconnection logic
|
||||
- Extract connection state management
|
||||
|
||||
5. **Create theme.js**:
|
||||
|
||||
- Extract `initTheme()` function
|
||||
- Extract `toggleTheme()` function
|
||||
- Extract `setTheme()` function
|
||||
- Extract theme persistence to localStorage
|
||||
|
||||
6. **Create ui-utils.js**:
|
||||
- Extract `showToast()` function
|
||||
- Extract `showLoadingOverlay()` / `hideLoadingOverlay()`
|
||||
- Extract `formatBytes()` function
|
||||
- Extract `formatDuration()` function
|
||||
- Extract `formatDate()` function
|
||||
- Extract any other shared UI helpers
|
||||
|
||||
**Pattern to Use (IIFE with Global Namespace)**:
|
||||
|
||||
```javascript
|
||||
// Example: shared/auth.js
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.Auth = (function () {
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "auth_token";
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
function removeToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: "Bearer " + token } : {};
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
function logout() {
|
||||
removeToken();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
getToken: getToken,
|
||||
setToken: setToken,
|
||||
getAuthHeaders: getAuthHeaders,
|
||||
checkAuth: checkAuth,
|
||||
logout: logout,
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Split app.js into Index Page Modules
|
||||
|
||||
**Objective**: Break down `app.js` into focused modules for the index/library page.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Create series-manager.js**:
|
||||
|
||||
- Extract series loading from API
|
||||
- Extract series filtering logic
|
||||
- Extract series rendering/DOM updates
|
||||
- Extract series card click handlers
|
||||
|
||||
2. **Create search.js**:
|
||||
|
||||
- Extract search input handling
|
||||
- Extract search API calls
|
||||
- Extract search results rendering
|
||||
- Extract search result selection
|
||||
|
||||
3. **Create scan-manager.js**:
|
||||
|
||||
- Extract scan initiation logic
|
||||
- Extract scan progress overlay
|
||||
- Extract scan progress updates (WebSocket)
|
||||
- Extract scan completion handling
|
||||
|
||||
4. **Create config-manager.js**:
|
||||
|
||||
- Extract config modal open/close
|
||||
- Extract config loading from API
|
||||
- Extract config form handling
|
||||
- Extract config save logic
|
||||
- Extract scheduler configuration
|
||||
- Extract backup management
|
||||
|
||||
5. **Create selection.js**:
|
||||
|
||||
- Extract episode selection logic
|
||||
- Extract "select all" functionality
|
||||
- Extract selection state management
|
||||
- Extract "add to queue" from selection
|
||||
|
||||
6. **Update main app.js**:
|
||||
- Import all modules via script tags
|
||||
- Initialize all modules on DOMContentLoaded
|
||||
- Wire up event listeners to module functions
|
||||
- Keep this file as thin as possible (orchestration only)
|
||||
|
||||
**Example main app.js structure**:
|
||||
|
||||
```javascript
|
||||
// filepath: src/server/web/static/js/app.js
|
||||
document.addEventListener("DOMContentLoaded", async function () {
|
||||
"use strict";
|
||||
|
||||
// Initialize shared modules
|
||||
AniWorld.Theme.init();
|
||||
|
||||
// Check authentication
|
||||
const isAuth = await AniWorld.Auth.checkAuth();
|
||||
if (!isAuth) return;
|
||||
|
||||
// Initialize page-specific modules
|
||||
AniWorld.SeriesManager.init();
|
||||
AniWorld.Search.init();
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.Selection.init();
|
||||
|
||||
// Initialize WebSocket for real-time updates
|
||||
AniWorld.WebSocketClient.init();
|
||||
|
||||
// Load initial data
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Split queue.js into Queue Page Modules
|
||||
|
||||
**Objective**: Break down `queue.js` into focused modules for the queue page.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Create queue-api.js**:
|
||||
|
||||
- Extract `loadQueueStatus()` API call
|
||||
- Extract `startDownload()` API call
|
||||
- Extract `stopDownload()` API call
|
||||
- Extract `removeFromQueue()` API call
|
||||
- Extract `clearCompleted()` API call
|
||||
- Extract `clearFailed()` API call
|
||||
- Extract `retryFailed()` API call
|
||||
|
||||
2. **Create queue-renderer.js**:
|
||||
|
||||
- Extract `renderActiveDownload()` function
|
||||
- Extract `renderPendingQueue()` function
|
||||
- Extract `renderCompletedList()` function
|
||||
- Extract `renderFailedList()` function
|
||||
- Extract `updateQueueCounts()` function
|
||||
- Extract queue item template generation
|
||||
|
||||
3. **Create progress-handler.js**:
|
||||
|
||||
- Extract WebSocket message handling for queue
|
||||
- Extract progress bar updates
|
||||
- Extract status text updates
|
||||
- Extract ETA calculations
|
||||
- Extract speed display formatting
|
||||
|
||||
4. **Update main queue.js**:
|
||||
- Import all modules via script tags
|
||||
- Initialize all modules on DOMContentLoaded
|
||||
- Wire up button click handlers to API functions
|
||||
- Set up WebSocket handlers for progress
|
||||
- Keep this file as thin as possible
|
||||
|
||||
**Example main queue.js structure**:
|
||||
|
||||
```javascript
|
||||
// filepath: src/server/web/static/js/queue.js
|
||||
document.addEventListener("DOMContentLoaded", async function () {
|
||||
"use strict";
|
||||
|
||||
// Initialize shared modules
|
||||
AniWorld.Theme.init();
|
||||
|
||||
// Check authentication
|
||||
const isAuth = await AniWorld.Auth.checkAuth();
|
||||
if (!isAuth) return;
|
||||
|
||||
// Initialize queue modules
|
||||
AniWorld.QueueApi.init();
|
||||
AniWorld.QueueRenderer.init();
|
||||
AniWorld.ProgressHandler.init();
|
||||
|
||||
// Initialize WebSocket with queue-specific handlers
|
||||
AniWorld.WebSocketClient.init({
|
||||
onProgress: AniWorld.ProgressHandler.handleProgress,
|
||||
onQueueUpdate: AniWorld.QueueRenderer.refresh,
|
||||
});
|
||||
|
||||
// Load initial queue status
|
||||
await AniWorld.QueueApi.loadStatus();
|
||||
AniWorld.QueueRenderer.refresh();
|
||||
|
||||
// Wire up UI buttons
|
||||
document
|
||||
.getElementById("start-btn")
|
||||
?.addEventListener("click", AniWorld.QueueApi.startDownload);
|
||||
document
|
||||
.getElementById("stop-btn")
|
||||
?.addEventListener("click", AniWorld.QueueApi.stopDownload);
|
||||
document
|
||||
.getElementById("clear-completed-btn")
|
||||
?.addEventListener("click", AniWorld.QueueApi.clearCompleted);
|
||||
document
|
||||
.getElementById("clear-failed-btn")
|
||||
?.addEventListener("click", AniWorld.QueueApi.clearFailed);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update HTML Templates
|
||||
|
||||
**Objective**: Update templates to load the new modular JavaScript files.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Update index.html**:
|
||||
|
||||
- Add script tags for shared modules (in order)
|
||||
- Add script tags for index-specific modules (in order)
|
||||
- Keep main app.js as the last script
|
||||
- Ensure correct load order (dependencies first)
|
||||
|
||||
```html
|
||||
<!-- Shared Modules -->
|
||||
<script src="/static/js/shared/constants.js"></script>
|
||||
<script src="/static/js/shared/auth.js"></script>
|
||||
<script src="/static/js/shared/api-client.js"></script>
|
||||
<script src="/static/js/shared/websocket-client.js"></script>
|
||||
<script src="/static/js/shared/theme.js"></script>
|
||||
<script src="/static/js/shared/ui-utils.js"></script>
|
||||
|
||||
<!-- Index Page Modules -->
|
||||
<script src="/static/js/index/series-manager.js"></script>
|
||||
<script src="/static/js/index/search.js"></script>
|
||||
<script src="/static/js/index/scan-manager.js"></script>
|
||||
<script src="/static/js/index/config-manager.js"></script>
|
||||
<script src="/static/js/index/selection.js"></script>
|
||||
|
||||
<!-- Main Entry Point -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
```
|
||||
|
||||
2. **Update queue.html**:
|
||||
|
||||
- Add script tags for shared modules (in order)
|
||||
- Add script tags for queue-specific modules (in order)
|
||||
- Keep main queue.js as the last script
|
||||
|
||||
```html
|
||||
<!-- Shared Modules -->
|
||||
<script src="/static/js/shared/constants.js"></script>
|
||||
<script src="/static/js/shared/auth.js"></script>
|
||||
<script src="/static/js/shared/api-client.js"></script>
|
||||
<script src="/static/js/shared/websocket-client.js"></script>
|
||||
<script src="/static/js/shared/theme.js"></script>
|
||||
<script src="/static/js/shared/ui-utils.js"></script>
|
||||
|
||||
<!-- Queue Page Modules -->
|
||||
<script src="/static/js/queue/queue-api.js"></script>
|
||||
<script src="/static/js/queue/queue-renderer.js"></script>
|
||||
<script src="/static/js/queue/progress-handler.js"></script>
|
||||
|
||||
<!-- Main Entry Point -->
|
||||
<script src="/static/js/queue.js"></script>
|
||||
```
|
||||
|
||||
3. **Update login.html** (if applicable):
|
||||
- Only include shared modules needed for login
|
||||
- Likely just theme.js and minimal utilities
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Verification and Testing
|
||||
|
||||
**Objective**: Ensure all functionality works after refactoring.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Start the server**:
|
||||
|
||||
```bash
|
||||
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
|
||||
```
|
||||
|
||||
2. **Test Login Page**:
|
||||
|
||||
- [ ] Page loads with correct styling
|
||||
- [ ] Dark/light mode toggle works
|
||||
- [ ] Login form submits correctly
|
||||
- [ ] Error messages display correctly
|
||||
- [ ] Successful login redirects to index
|
||||
|
||||
3. **Test Index Page**:
|
||||
|
||||
- [ ] Page loads with correct styling
|
||||
- [ ] Series list loads and displays
|
||||
- [ ] Series filtering works
|
||||
- [ ] Search functionality works
|
||||
- [ ] Series selection works
|
||||
- [ ] Episode selection works
|
||||
- [ ] Add to queue works
|
||||
- [ ] Scan library works
|
||||
- [ ] Scan progress displays
|
||||
- [ ] Config modal opens/closes
|
||||
- [ ] Config saves correctly
|
||||
- [ ] Dark/light mode toggle works
|
||||
- [ ] Logout works
|
||||
- [ ] WebSocket connection established
|
||||
|
||||
4. **Test Queue Page**:
|
||||
|
||||
- [ ] Page loads with correct styling
|
||||
- [ ] Queue status loads
|
||||
- [ ] Pending items display
|
||||
- [ ] Active download displays
|
||||
- [ ] Completed items display
|
||||
- [ ] Failed items display
|
||||
- [ ] Start download works
|
||||
- [ ] Stop download works
|
||||
- [ ] Remove from queue works
|
||||
- [ ] Clear completed works
|
||||
- [ ] Clear failed works
|
||||
- [ ] Retry failed works
|
||||
- [ ] Progress updates in real-time
|
||||
- [ ] Dark/light mode toggle works
|
||||
- [ ] WebSocket connection established
|
||||
|
||||
5. **Test Responsive Design**:
|
||||
|
||||
- [ ] All pages work on mobile viewport
|
||||
- [ ] All pages work on tablet viewport
|
||||
- [ ] All pages work on desktop viewport
|
||||
|
||||
6. **Browser Console Check**:
|
||||
- [ ] No JavaScript errors in console
|
||||
- [ ] No 404 errors for static files
|
||||
- [ ] No CSS loading errors
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Cleanup and Documentation
|
||||
|
||||
**Objective**: Finalize the refactoring with cleanup and documentation.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. **Remove backup files** (if any were created)
|
||||
|
||||
2. **Verify file sizes**:
|
||||
|
||||
- No file should exceed 500 lines
|
||||
- If any file exceeds, split further
|
||||
|
||||
3. **Add file headers**:
|
||||
|
||||
- Add comment header to each new file explaining its purpose
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* AniWorld - Series Manager Module
|
||||
*
|
||||
* Handles loading, filtering, and rendering of anime series
|
||||
* on the index/library page.
|
||||
*
|
||||
* Dependencies: auth.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
```
|
||||
|
||||
```css
|
||||
/**
|
||||
* AniWorld - Button Styles
|
||||
*
|
||||
* All button-related styles including variants,
|
||||
* states, and sizes.
|
||||
*/
|
||||
```
|
||||
|
||||
4. **Update infrastructure.md** (if exists):
|
||||
|
||||
- Document new file structure
|
||||
- Document module dependencies
|
||||
|
||||
5. **Commit changes**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "refactor: split CSS and JS into modular files (SRP)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Summary of New Files
|
||||
|
||||
**CSS Files (14 files)**:
|
||||
|
||||
- `css/styles.css` (entry point with imports)
|
||||
- `css/base/variables.css`
|
||||
- `css/base/reset.css`
|
||||
- `css/base/typography.css`
|
||||
- `css/components/buttons.css`
|
||||
- `css/components/cards.css`
|
||||
- `css/components/forms.css`
|
||||
- `css/components/modals.css`
|
||||
- `css/components/navigation.css`
|
||||
- `css/components/progress.css`
|
||||
- `css/components/notifications.css`
|
||||
- `css/components/tables.css`
|
||||
- `css/pages/login.css`
|
||||
- `css/pages/index.css`
|
||||
- `css/pages/queue.css`
|
||||
- `css/utilities/animations.css`
|
||||
- `css/utilities/responsive.css`
|
||||
- `css/utilities/helpers.css`
|
||||
|
||||
**JavaScript Files (15 files)**:
|
||||
|
||||
- `js/app.js` (entry point for index)
|
||||
- `js/queue.js` (entry point for queue)
|
||||
- `js/shared/constants.js`
|
||||
- `js/shared/auth.js`
|
||||
- `js/shared/api-client.js`
|
||||
- `js/shared/websocket-client.js`
|
||||
- `js/shared/theme.js`
|
||||
- `js/shared/ui-utils.js`
|
||||
- `js/index/series-manager.js`
|
||||
- `js/index/search.js`
|
||||
- `js/index/scan-manager.js`
|
||||
- `js/index/config-manager.js`
|
||||
- `js/index/selection.js`
|
||||
- `js/queue/queue-api.js`
|
||||
- `js/queue/queue-renderer.js`
|
||||
- `js/queue/progress-handler.js`
|
||||
|
||||
---
|
||||
|
||||
### Important Notes
|
||||
|
||||
1. **IIFE Pattern**: Use the IIFE (Immediately Invoked Function Expression) pattern with a global namespace (`AniWorld`) for browser compatibility without requiring a build step.
|
||||
|
||||
2. **No Build Tools Required**: This approach uses native CSS `@import` and multiple `<script>` tags, avoiding the need for bundlers like Webpack or Vite.
|
||||
|
||||
3. **Load Order Matters**: Scripts must be loaded in dependency order. Shared modules first, then page-specific modules, then the main entry point.
|
||||
|
||||
4. **Backward Compatibility**: All existing HTML element IDs and class names must be preserved. Only the JavaScript and CSS organization changes, not the API.
|
||||
|
||||
5. **Incremental Approach**: Complete one task fully before moving to the next. Verify functionality after each major step.
|
||||
|
||||
6. **Rollback Plan**: Keep the original files until all verification is complete. Only delete originals after confirming everything works.
|
||||
|
||||
33
src/server/web/static/css/base/reset.css
Normal file
33
src/server/web/static/css/base/reset.css
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* AniWorld - CSS Reset
|
||||
*
|
||||
* Normalize and reset default browser styles
|
||||
* for consistent cross-browser rendering.
|
||||
*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-primary);
|
||||
transition: background-color var(--transition-duration) var(--transition-easing),
|
||||
color var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
/* App container */
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
51
src/server/web/static/css/base/typography.css
Normal file
51
src/server/web/static/css/base/typography.css
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* AniWorld - Typography Styles
|
||||
*
|
||||
* Font styles, headings, and text utilities.
|
||||
*/
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-large-title);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-subtitle);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error);
|
||||
font-weight: 500;
|
||||
}
|
||||
114
src/server/web/static/css/base/variables.css
Normal file
114
src/server/web/static/css/base/variables.css
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* AniWorld - CSS Variables
|
||||
*
|
||||
* Fluent UI Design System custom properties for colors, typography,
|
||||
* spacing, borders, shadows, and transitions.
|
||||
* Includes both light and dark theme definitions.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Light theme colors */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #faf9f8;
|
||||
--color-bg-tertiary: #f3f2f1;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-hover: #f3f2f1;
|
||||
--color-surface-pressed: #edebe9;
|
||||
--color-text-primary: #323130;
|
||||
--color-text-secondary: #605e5c;
|
||||
--color-text-tertiary: #a19f9d;
|
||||
--color-accent: #0078d4;
|
||||
--color-accent-hover: #106ebe;
|
||||
--color-accent-pressed: #005a9e;
|
||||
--color-success: #107c10;
|
||||
--color-warning: #ff8c00;
|
||||
--color-error: #d13438;
|
||||
--color-border: #e1dfdd;
|
||||
--color-divider: #c8c6c4;
|
||||
|
||||
/* Dark theme colors (stored as variables for theme switching) */
|
||||
--color-bg-primary-dark: #202020;
|
||||
--color-bg-secondary-dark: #2d2d30;
|
||||
--color-bg-tertiary-dark: #3e3e42;
|
||||
--color-surface-dark: #292929;
|
||||
--color-surface-hover-dark: #3e3e42;
|
||||
--color-surface-pressed-dark: #484848;
|
||||
--color-text-primary-dark: #ffffff;
|
||||
--color-text-secondary-dark: #cccccc;
|
||||
--color-text-tertiary-dark: #969696;
|
||||
--color-accent-dark: #60cdff;
|
||||
--color-accent-hover-dark: #4db8e8;
|
||||
--color-accent-pressed-dark: #3aa0d1;
|
||||
--color-border-dark: #484644;
|
||||
--color-divider-dark: #605e5c;
|
||||
|
||||
/* Typography */
|
||||
--font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
|
||||
--font-size-caption: 12px;
|
||||
--font-size-body: 14px;
|
||||
--font-size-subtitle: 16px;
|
||||
--font-size-title: 20px;
|
||||
--font-size-large-title: 32px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
--spacing-xxl: 24px;
|
||||
|
||||
/* Border radius */
|
||||
--border-radius-sm: 2px;
|
||||
--border-radius-md: 4px;
|
||||
--border-radius-lg: 6px;
|
||||
--border-radius-xl: 8px;
|
||||
--border-radius: var(--border-radius-md);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 1.6px 3.6px 0 rgba(0, 0, 0, 0.132), 0 0.3px 0.9px 0 rgba(0, 0, 0, 0.108);
|
||||
--shadow-elevated: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108);
|
||||
|
||||
/* Transitions */
|
||||
--transition-duration: 0.15s;
|
||||
--transition-easing: cubic-bezier(0.1, 0.9, 0.2, 1);
|
||||
--animation-duration-fast: 0.1s;
|
||||
--animation-duration-normal: 0.15s;
|
||||
--animation-easing-standard: cubic-bezier(0.1, 0.9, 0.2, 1);
|
||||
|
||||
/* Additional color aliases */
|
||||
--color-primary: var(--color-accent);
|
||||
--color-primary-light: #e6f2fb;
|
||||
--color-primary-dark: #005a9e;
|
||||
--color-text: var(--color-text-primary);
|
||||
--color-text-disabled: #a19f9d;
|
||||
--color-background: var(--color-bg-primary);
|
||||
--color-background-secondary: var(--color-bg-secondary);
|
||||
--color-background-tertiary: var(--color-bg-tertiary);
|
||||
--color-background-subtle: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--color-bg-primary: var(--color-bg-primary-dark);
|
||||
--color-bg-secondary: var(--color-bg-secondary-dark);
|
||||
--color-bg-tertiary: var(--color-bg-tertiary-dark);
|
||||
--color-surface: var(--color-surface-dark);
|
||||
--color-surface-hover: var(--color-surface-hover-dark);
|
||||
--color-surface-pressed: var(--color-surface-pressed-dark);
|
||||
--color-text-primary: var(--color-text-primary-dark);
|
||||
--color-text-secondary: var(--color-text-secondary-dark);
|
||||
--color-text-tertiary: var(--color-text-tertiary-dark);
|
||||
--color-accent: var(--color-accent-dark);
|
||||
--color-accent-hover: var(--color-accent-hover-dark);
|
||||
--color-accent-pressed: var(--color-accent-pressed-dark);
|
||||
--color-border: var(--color-border-dark);
|
||||
--color-divider: var(--color-divider-dark);
|
||||
--color-text: var(--color-text-primary-dark);
|
||||
--color-text-disabled: #969696;
|
||||
--color-background: var(--color-bg-primary-dark);
|
||||
--color-background-secondary: var(--color-bg-secondary-dark);
|
||||
--color-background-tertiary: var(--color-bg-tertiary-dark);
|
||||
--color-background-subtle: var(--color-bg-tertiary-dark);
|
||||
--color-primary-light: #1a3a5c;
|
||||
}
|
||||
123
src/server/web/static/css/components/buttons.css
Normal file
123
src/server/web/static/css/components/buttons.css
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* AniWorld - Button Styles
|
||||
*
|
||||
* All button-related styles including variants,
|
||||
* states, and sizes.
|
||||
*/
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
background-color: var(--color-accent-pressed);
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
background-color: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
/* Success button */
|
||||
.btn-success {
|
||||
background-color: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background-color: #0e6b0e;
|
||||
}
|
||||
|
||||
/* Warning button */
|
||||
.btn-warning {
|
||||
background-color: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background-color: #e67e00;
|
||||
}
|
||||
|
||||
/* Danger/Error button */
|
||||
.btn-danger {
|
||||
background-color: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #b52d30;
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon {
|
||||
padding: var(--spacing-sm);
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Small button */
|
||||
.btn-small {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
/* Extra small button */
|
||||
.btn-xs {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Filter button active state */
|
||||
.series-filters .btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.series-filters .btn[data-active="true"] {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
|
||||
}
|
||||
|
||||
.series-filters .btn[data-active="true"]:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .series-filters .btn[data-active="true"] {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
271
src/server/web/static/css/components/cards.css
Normal file
271
src/server/web/static/css/components/cards.css
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* AniWorld - Card Styles
|
||||
*
|
||||
* Card and panel component styles including
|
||||
* series cards and stat cards.
|
||||
*/
|
||||
|
||||
/* Series Card */
|
||||
.series-card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.series-card:hover {
|
||||
box-shadow: var(--shadow-elevated);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.series-card.selected {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.series-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-md);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.series-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.series-info h3 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.series-folder {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.series-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.series-site {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Series Card Status Indicators */
|
||||
.series-status {
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
right: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-missing {
|
||||
color: var(--color-warning);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.status-complete {
|
||||
color: var(--color-success);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Series Card States */
|
||||
.series-card.has-missing {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.series-card.complete {
|
||||
border-left: 4px solid var(--color-success);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.series-card.complete .series-checkbox {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.series-card.complete:not(.selected) {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .series-card.complete:not(.selected) {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
/* Stat Card */
|
||||
.stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
background: var(--color-surface-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-title);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Download Card */
|
||||
.download-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
background: var(--color-surface-hover);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.download-card.active {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.download-card.completed {
|
||||
border-left: 4px solid var(--color-success);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.download-card.failed {
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.download-card.pending {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.download-card.pending.high-priority {
|
||||
border-left-color: var(--color-accent);
|
||||
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%);
|
||||
}
|
||||
|
||||
.download-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.download-info h4 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.download-info p {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.download-info small {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.priority-indicator {
|
||||
color: var(--color-accent);
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Queue Position */
|
||||
.queue-position {
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
left: 48px;
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.download-card.pending .download-info {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
.download-card.pending .download-header {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Dark Theme Adjustments for Cards */
|
||||
[data-theme="dark"] .stat-card {
|
||||
background: var(--color-surface-dark);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .stat-card:hover {
|
||||
background: var(--color-surface-hover-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .download-card {
|
||||
background: var(--color-surface-dark);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .download-card:hover {
|
||||
background: var(--color-surface-hover-dark);
|
||||
}
|
||||
224
src/server/web/static/css/components/forms.css
Normal file
224
src/server/web/static/css/components/forms.css
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* AniWorld - Form Styles
|
||||
*
|
||||
* Form inputs, labels, validation states,
|
||||
* and form group layouts.
|
||||
*/
|
||||
|
||||
/* Input fields */
|
||||
.input-field {
|
||||
width: 120px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-body);
|
||||
transition: border-color var(--animation-duration-fast) var(--animation-easing-standard);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.input-group .input-field {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-body);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
/* Checkbox custom styling */
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
position: relative;
|
||||
transition: all var(--animation-duration-fast) var(--animation-easing-standard);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:checked+.checkbox-custom {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:checked+.checkbox-custom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.checkbox-label:hover .checkbox-custom {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Form groups */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Config item styling */
|
||||
.config-item {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.config-value {
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-value input[readonly] {
|
||||
background-color: var(--color-bg-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .config-value input[readonly] {
|
||||
background-color: var(--color-bg-secondary-dark);
|
||||
}
|
||||
|
||||
/* Config description */
|
||||
.config-description {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text);
|
||||
margin: 4px 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Config actions */
|
||||
.config-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Validation styles */
|
||||
.validation-results {
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.validation-results.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: var(--color-error);
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.validation-warning {
|
||||
color: var(--color-warning);
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.validation-success {
|
||||
color: var(--color-success);
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Responsive form adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.config-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
264
src/server/web/static/css/components/modals.css
Normal file
264
src/server/web/static/css/components/modals.css
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* AniWorld - Modal Styles
|
||||
*
|
||||
* Modal and overlay styles including
|
||||
* config modal and confirmation dialogs.
|
||||
*/
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Config Section within modals */
|
||||
.config-section {
|
||||
border-top: 1px solid var(--color-divider);
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.config-section h4 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Scheduler info box */
|
||||
.scheduler-info {
|
||||
background: var(--color-background-subtle);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-md);
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background: var(--color-text-disabled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Rescan time config */
|
||||
#rescan-time-config {
|
||||
margin-left: var(--spacing-lg);
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--animation-duration-normal) var(--animation-easing-standard);
|
||||
}
|
||||
|
||||
#rescan-time-config.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.loading-spinner p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
}
|
||||
|
||||
/* Backup list */
|
||||
.backup-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backup-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backup-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.backup-details {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backup-actions .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Log files container */
|
||||
.log-files-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.log-file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.log-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-file-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-file-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.log-file-details {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-file-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.log-file-actions .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.log-file-actions .btn-xs {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.info-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
218
src/server/web/static/css/components/navigation.css
Normal file
218
src/server/web/static/css/components/navigation.css
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* AniWorld - Navigation Styles
|
||||
*
|
||||
* Header, nav, and navigation link styles.
|
||||
*/
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background-color: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: background-color var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 60px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex-shrink: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.header-title i {
|
||||
font-size: var(--font-size-title);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Series section */
|
||||
.series-section {
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
}
|
||||
|
||||
.series-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.series-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.series-filters {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.series-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.series-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Search section */
|
||||
.search-section {
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .section-header {
|
||||
border-bottom-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (min-width: 768px) {
|
||||
.series-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.series-filters {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.header-title {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
text-align: center;
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.series-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.series-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.series-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.download-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.download-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
148
src/server/web/static/css/components/notifications.css
Normal file
148
src/server/web/static/css/components/notifications.css
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* AniWorld - Notification Styles
|
||||
*
|
||||
* Toast notifications, alerts, and messages.
|
||||
*/
|
||||
|
||||
/* Toast container */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: var(--spacing-xl);
|
||||
right: var(--spacing-xl);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Toast base */
|
||||
.toast {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
min-width: 300px;
|
||||
animation: slideIn var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
/* Toast variants */
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* Status panel */
|
||||
.status-panel {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-xl);
|
||||
right: var(--spacing-xl);
|
||||
width: 400px;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
z-index: 1000;
|
||||
transition: all var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.status-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.status-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-error);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Download controls */
|
||||
.download-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xxl);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
}
|
||||
|
||||
.empty-state small {
|
||||
display: block;
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: var(--font-size-small);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.status-panel {
|
||||
bottom: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
left: var(--spacing-md);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
196
src/server/web/static/css/components/progress.css
Normal file
196
src/server/web/static/css/components/progress.css
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* AniWorld - Progress Styles
|
||||
*
|
||||
* Progress bars, loading indicators,
|
||||
* and download progress displays.
|
||||
*/
|
||||
|
||||
/* Progress bar base */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: width var(--transition-duration) var(--transition-easing);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-top: var(--spacing-xs);
|
||||
text-align: center;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Progress container */
|
||||
.progress-container {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Mini progress bar */
|
||||
.progress-bar-mini {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill-mini {
|
||||
height: 100%;
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: width var(--transition-duration) var(--transition-easing);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-text-mini {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
/* Download progress */
|
||||
.download-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
min-width: 120px;
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Progress bar gradient style */
|
||||
.download-progress .progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.download-progress .progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.download-speed {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Missing episodes status */
|
||||
.missing-episodes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.missing-episodes i {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.missing-episodes.has-missing {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.missing-episodes.complete {
|
||||
color: var(--color-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.missing-episodes.has-missing i {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.missing-episodes.complete i {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Speed and ETA section */
|
||||
.speed-eta-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.speed-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.speed-current,
|
||||
.speed-average,
|
||||
.eta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.speed-info .label,
|
||||
.eta-info .label {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.speed-info .value,
|
||||
.eta-info .value {
|
||||
font-size: var(--font-size-subtitle);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .speed-eta-section {
|
||||
background: var(--color-surface-dark);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.current-download-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.download-progress {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.speed-eta-section {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.speed-info {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
128
src/server/web/static/css/components/status.css
Normal file
128
src/server/web/static/css/components/status.css
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* AniWorld - Process Status Styles
|
||||
*
|
||||
* Process status indicators for scan and download operations.
|
||||
*/
|
||||
|
||||
/* Process Status Indicators */
|
||||
.process-status {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator:hover {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.status-indicator i {
|
||||
font-size: 24px;
|
||||
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
|
||||
}
|
||||
|
||||
/* Status dots */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
|
||||
}
|
||||
|
||||
.status-dot.idle {
|
||||
background-color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background-color: var(--color-accent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
/* Rescan icon specific styling */
|
||||
#rescan-status {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rescan-status i {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
#rescan-status.running i {
|
||||
color: #22c55e;
|
||||
animation: iconPulse 2s infinite;
|
||||
}
|
||||
|
||||
#rescan-status.running {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile view */
|
||||
@media (max-width: 1024px) {
|
||||
.process-status {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.process-status {
|
||||
order: -1;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-indicator i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
255
src/server/web/static/css/components/tables.css
Normal file
255
src/server/web/static/css/components/tables.css
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* AniWorld - Table Styles
|
||||
*
|
||||
* Table, list, and queue item styles.
|
||||
*/
|
||||
|
||||
/* Search results */
|
||||
.search-results {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.search-results h3 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: background-color var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Download Queue Section */
|
||||
.download-queue-section {
|
||||
margin-bottom: var(--spacing-xxl);
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.queue-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-subtitle);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.queue-header i {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.queue-progress {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Current download */
|
||||
.current-download {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.current-download-header {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.current-download-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current-download-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.download-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.serie-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Queue list */
|
||||
.queue-list-container {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.queue-list-container h3 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
border-left: 3px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.queue-item-index {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.queue-item-name {
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.queue-item-status {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Stats grid */
|
||||
.queue-stats-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Drag and Drop Styles */
|
||||
.draggable-item {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.draggable-item.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.98);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.draggable-item.drag-over {
|
||||
border-top: 3px solid var(--color-primary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: grab;
|
||||
font-size: 1.2rem;
|
||||
padding: var(--spacing-xs);
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sortable-list {
|
||||
position: relative;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.pending-queue-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.queue-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.queue-item-index {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
230
src/server/web/static/css/pages/index.css
Normal file
230
src/server/web/static/css/pages/index.css
Normal file
@ -0,0 +1,230 @@
|
||||
/**
|
||||
* AniWorld - Index Page Styles
|
||||
*
|
||||
* Index/library page specific styles including
|
||||
* series grid, search, and scan overlay.
|
||||
*/
|
||||
|
||||
/* Scan Progress Overlay */
|
||||
.scan-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.scan-progress-overlay.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.scan-progress-container {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: var(--spacing-xxl);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
animation: scanProgressSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes scanProgressSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-progress-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.scan-progress-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.scan-progress-spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--color-bg-tertiary);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: scanSpinner 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scanSpinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress bar for scan */
|
||||
.scan-progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.scan-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover, var(--color-accent)));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.scan-progress-container.completed .scan-progress-bar {
|
||||
background: linear-gradient(90deg, var(--color-success), var(--color-success));
|
||||
}
|
||||
|
||||
.scan-progress-text {
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.scan-progress-text #scan-current-count {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.scan-progress-text #scan-total-count {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.scan-progress-container.completed .scan-progress-text #scan-current-count {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.scan-progress-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: var(--spacing-lg) 0;
|
||||
padding: var(--spacing-md) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.scan-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scan-stat-value {
|
||||
font-size: var(--font-size-large-title);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.scan-stat-label {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.scan-current-directory {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.scan-current-directory-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Scan completed state */
|
||||
.scan-progress-container.completed .scan-progress-spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scan-progress-container.completed .scan-progress-header h3 {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.scan-completed-icon {
|
||||
display: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.scan-progress-container.completed .scan-completed-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.scan-progress-container.completed .scan-stat-value {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.scan-elapsed-time {
|
||||
margin-top: var(--spacing-md);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scan-elapsed-time i {
|
||||
margin-right: var(--spacing-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for scan overlay */
|
||||
@media (max-width: 768px) {
|
||||
.scan-progress-container {
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.scan-progress-stats {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.scan-stat {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.scan-stat-value {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
}
|
||||
168
src/server/web/static/css/pages/login.css
Normal file
168
src/server/web/static/css/pages/login.css
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* AniWorld - Login Page Styles
|
||||
*
|
||||
* Login page specific styles including login card,
|
||||
* form elements, and branding.
|
||||
*/
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header .logo {
|
||||
font-size: 3rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Password input group */
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 3rem 0.75rem 1rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.password-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Login button */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.login-error {
|
||||
background: rgba(var(--color-error-rgb, 209, 52, 56), 0.1);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: var(--color-error);
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Remember me checkbox */
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Footer links */
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
46
src/server/web/static/css/pages/queue.css
Normal file
46
src/server/web/static/css/pages/queue.css
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* AniWorld - Queue Page Styles
|
||||
*
|
||||
* Queue page specific styles for download management.
|
||||
*/
|
||||
|
||||
/* Active downloads section */
|
||||
.active-downloads-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.active-downloads-list {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Pending queue section */
|
||||
.pending-queue-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Completed downloads section */
|
||||
.completed-downloads-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Failed downloads section */
|
||||
.failed-downloads-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Queue page text color utilities */
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
160
src/server/web/static/css/utilities/animations.css
Normal file
160
src/server/web/static/css/utilities/animations.css
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* AniWorld - Animation Styles
|
||||
*
|
||||
* Keyframes and animation utility classes.
|
||||
*/
|
||||
|
||||
/* Slide in animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade out animation */
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide up animation */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide down animation */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale in animation */
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spin animation for loading */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce animation */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulsate {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation utility classes */
|
||||
.animate-slide-in {
|
||||
animation: slideIn var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fadeOut var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn var(--transition-duration) var(--transition-easing);
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s ease;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulsate 2s ease-in-out infinite;
|
||||
}
|
||||
368
src/server/web/static/css/utilities/helpers.css
Normal file
368
src/server/web/static/css/utilities/helpers.css
Normal file
@ -0,0 +1,368 @@
|
||||
/**
|
||||
* AniWorld - Helper Utilities
|
||||
*
|
||||
* Utility classes for visibility, spacing, flexbox, and text.
|
||||
*/
|
||||
|
||||
/* Display utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
/* Flexbox utilities */
|
||||
.flex-row {
|
||||
flex-direction: row !important;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.flex-nowrap {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.justify-start {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.justify-around {
|
||||
justify-content: space-around !important;
|
||||
}
|
||||
|
||||
.align-start {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.align-end {
|
||||
align-items: flex-end !important;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.align-stretch {
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto !important;
|
||||
}
|
||||
|
||||
.flex-none {
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
/* Text alignment */
|
||||
.text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
/* Text transformation */
|
||||
.text-uppercase {
|
||||
text-transform: uppercase !important;
|
||||
}
|
||||
|
||||
.text-lowercase {
|
||||
text-transform: lowercase !important;
|
||||
}
|
||||
|
||||
.text-capitalize {
|
||||
text-transform: capitalize !important;
|
||||
}
|
||||
|
||||
/* Font weight */
|
||||
.font-normal {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Margins */
|
||||
.m-0 {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ml-0 {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.mr-0 {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: var(--spacing-xs) !important;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: var(--spacing-sm) !important;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: var(--spacing-md) !important;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: var(--spacing-lg) !important;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: var(--spacing-xs) !important;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: var(--spacing-sm) !important;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: var(--spacing-md) !important;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--spacing-lg) !important;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
/* Padding */
|
||||
.p-0 {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: var(--spacing-xs) !important;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: var(--spacing-sm) !important;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: var(--spacing-md) !important;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: var(--spacing-lg) !important;
|
||||
}
|
||||
|
||||
/* Width utilities */
|
||||
.w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.w-auto {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* Height utilities */
|
||||
.h-full {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.h-auto {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Overflow */
|
||||
.overflow-hidden {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.overflow-scroll {
|
||||
overflow: scroll !important;
|
||||
}
|
||||
|
||||
/* Position */
|
||||
.relative {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
/* Cursor */
|
||||
.cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* User select */
|
||||
.select-none {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.select-text {
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
user-select: all !important;
|
||||
}
|
||||
|
||||
/* Border radius */
|
||||
.rounded {
|
||||
border-radius: var(--border-radius-md) !important;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: var(--border-radius-lg) !important;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
|
||||
.rounded-none {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Shadow */
|
||||
.shadow {
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: var(--shadow-elevated) !important;
|
||||
}
|
||||
|
||||
.shadow-none {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Opacity */
|
||||
.opacity-0 {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.opacity-50 {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.opacity-100 {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.transition {
|
||||
transition: all var(--transition-duration) var(--transition-easing) !important;
|
||||
}
|
||||
|
||||
.transition-none {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Z-index */
|
||||
.z-0 {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.z-50 {
|
||||
z-index: 50 !important;
|
||||
}
|
||||
|
||||
.z-100 {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
117
src/server/web/static/css/utilities/responsive.css
Normal file
117
src/server/web/static/css/utilities/responsive.css
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AniWorld - Responsive Styles
|
||||
*
|
||||
* Media queries and breakpoint-specific styles.
|
||||
* Note: Component-specific responsive styles are in their respective files.
|
||||
* This file contains global responsive utilities and overrides.
|
||||
*/
|
||||
|
||||
/* Small devices (landscape phones, 576px and up) */
|
||||
@media (min-width: 576px) {
|
||||
.container-sm {
|
||||
max-width: 540px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium devices (tablets, 768px and up) */
|
||||
@media (min-width: 768px) {
|
||||
.container-md {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.hide-md-up {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large devices (desktops, 992px and up) */
|
||||
@media (min-width: 992px) {
|
||||
.container-lg {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.hide-lg-up {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra large devices (large desktops, 1200px and up) */
|
||||
@media (min-width: 1200px) {
|
||||
.container-xl {
|
||||
max-width: 1140px;
|
||||
}
|
||||
|
||||
.hide-xl-up {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on small screens */
|
||||
@media (max-width: 575.98px) {
|
||||
.hide-sm-down {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on medium screens and below */
|
||||
@media (max-width: 767.98px) {
|
||||
.hide-md-down {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on large screens and below */
|
||||
@media (max-width: 991.98px) {
|
||||
.hide-lg-down {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.header,
|
||||
.toast-container,
|
||||
.status-panel {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--color-border: #000000;
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #333333;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-border: #ffffff;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #cccccc;
|
||||
}
|
||||
}
|
||||
74
src/server/web/static/js/index/advanced-config.js
Normal file
74
src/server/web/static/js/index/advanced-config.js
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* AniWorld - Advanced Config Module
|
||||
*
|
||||
* Handles advanced configuration settings like concurrent downloads,
|
||||
* timeouts, and debug mode.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.AdvancedConfig = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Load advanced configuration
|
||||
*/
|
||||
async function load() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.CONFIG_SECTION + '/advanced');
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const config = data.config;
|
||||
document.getElementById('max-concurrent-downloads').value = config.max_concurrent_downloads || 3;
|
||||
document.getElementById('provider-timeout').value = config.provider_timeout || 30;
|
||||
document.getElementById('enable-debug-mode').checked = config.enable_debug_mode === true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading advanced config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save advanced configuration
|
||||
*/
|
||||
async function save() {
|
||||
try {
|
||||
const config = {
|
||||
max_concurrent_downloads: parseInt(document.getElementById('max-concurrent-downloads').value),
|
||||
provider_timeout: parseInt(document.getElementById('provider-timeout').value),
|
||||
enable_debug_mode: document.getElementById('enable-debug-mode').checked
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.request(API.CONFIG_SECTION + '/advanced', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Advanced configuration saved successfully', 'success');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to save config: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving advanced config:', error);
|
||||
AniWorld.UI.showToast('Failed to save advanced configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
load: load,
|
||||
save: save
|
||||
};
|
||||
})();
|
||||
103
src/server/web/static/js/index/app-init.js
Normal file
103
src/server/web/static/js/index/app-init.js
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* AniWorld - Index Page Application Initializer
|
||||
*
|
||||
* Main entry point for the index page. Initializes all modules.
|
||||
*
|
||||
* Dependencies: All shared and index modules
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.IndexApp = (function() {
|
||||
'use strict';
|
||||
|
||||
let localization = null;
|
||||
|
||||
/**
|
||||
* Initialize the index page application
|
||||
*/
|
||||
async function init() {
|
||||
console.log('AniWorld Index App initializing...');
|
||||
|
||||
// Initialize localization if available
|
||||
if (typeof Localization !== 'undefined') {
|
||||
localization = new Localization();
|
||||
}
|
||||
|
||||
// Check authentication first
|
||||
const isAuthenticated = await AniWorld.Auth.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
return; // Auth module handles redirect
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
AniWorld.Theme.init();
|
||||
|
||||
// Initialize WebSocket connection
|
||||
AniWorld.WebSocketClient.init();
|
||||
|
||||
// Initialize socket event handlers for this page
|
||||
AniWorld.IndexSocketHandler.init(localization);
|
||||
|
||||
// Initialize page modules
|
||||
AniWorld.SeriesManager.init();
|
||||
AniWorld.SelectionManager.init();
|
||||
AniWorld.Search.init();
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
// Load initial data
|
||||
await AniWorld.SeriesManager.loadSeries();
|
||||
|
||||
console.log('AniWorld Index App initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind global event handlers
|
||||
*/
|
||||
function bindGlobalEvents() {
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
AniWorld.Theme.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// Logout button
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
AniWorld.Auth.logout(AniWorld.UI.showToast);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localization instance
|
||||
*/
|
||||
function getLocalization() {
|
||||
return localization;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
getLocalization: getLocalization
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
AniWorld.IndexApp.init();
|
||||
});
|
||||
|
||||
// Expose app globally for inline event handlers (backwards compatibility)
|
||||
window.app = {
|
||||
addSeries: function(link, name) {
|
||||
return AniWorld.Search.addSeries(link, name);
|
||||
}
|
||||
};
|
||||
229
src/server/web/static/js/index/config-manager.js
Normal file
229
src/server/web/static/js/index/config-manager.js
Normal file
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AniWorld - Config Manager Module
|
||||
*
|
||||
* Orchestrates configuration modal and delegates to specialized config modules.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js,
|
||||
* scheduler-config.js, logging-config.js, advanced-config.js, main-config.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ConfigManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Initialize the config manager
|
||||
*/
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI events
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Config modal
|
||||
const configBtn = document.getElementById('config-btn');
|
||||
if (configBtn) {
|
||||
configBtn.addEventListener('click', showConfigModal);
|
||||
}
|
||||
|
||||
const closeConfig = document.getElementById('close-config');
|
||||
if (closeConfig) {
|
||||
closeConfig.addEventListener('click', hideConfigModal);
|
||||
}
|
||||
|
||||
const configModal = document.querySelector('#config-modal .modal-overlay');
|
||||
if (configModal) {
|
||||
configModal.addEventListener('click', hideConfigModal);
|
||||
}
|
||||
|
||||
// Scheduler configuration
|
||||
bindSchedulerEvents();
|
||||
|
||||
// Logging configuration
|
||||
bindLoggingEvents();
|
||||
|
||||
// Advanced configuration
|
||||
bindAdvancedEvents();
|
||||
|
||||
// Main configuration
|
||||
bindMainEvents();
|
||||
|
||||
// Status panel
|
||||
const closeStatus = document.getElementById('close-status');
|
||||
if (closeStatus) {
|
||||
closeStatus.addEventListener('click', hideStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind scheduler-related events
|
||||
*/
|
||||
function bindSchedulerEvents() {
|
||||
const schedulerEnabled = document.getElementById('scheduled-rescan-enabled');
|
||||
if (schedulerEnabled) {
|
||||
schedulerEnabled.addEventListener('change', AniWorld.SchedulerConfig.toggleTimeInput);
|
||||
}
|
||||
|
||||
const saveScheduler = document.getElementById('save-scheduler-config');
|
||||
if (saveScheduler) {
|
||||
saveScheduler.addEventListener('click', AniWorld.SchedulerConfig.save);
|
||||
}
|
||||
|
||||
const testScheduler = document.getElementById('test-scheduled-rescan');
|
||||
if (testScheduler) {
|
||||
testScheduler.addEventListener('click', AniWorld.SchedulerConfig.testRescan);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind logging-related events
|
||||
*/
|
||||
function bindLoggingEvents() {
|
||||
const saveLogging = document.getElementById('save-logging-config');
|
||||
if (saveLogging) {
|
||||
saveLogging.addEventListener('click', AniWorld.LoggingConfig.save);
|
||||
}
|
||||
|
||||
const testLogging = document.getElementById('test-logging');
|
||||
if (testLogging) {
|
||||
testLogging.addEventListener('click', AniWorld.LoggingConfig.testLogging);
|
||||
}
|
||||
|
||||
const refreshLogs = document.getElementById('refresh-log-files');
|
||||
if (refreshLogs) {
|
||||
refreshLogs.addEventListener('click', AniWorld.LoggingConfig.loadLogFiles);
|
||||
}
|
||||
|
||||
const cleanupLogs = document.getElementById('cleanup-logs');
|
||||
if (cleanupLogs) {
|
||||
cleanupLogs.addEventListener('click', AniWorld.LoggingConfig.cleanupLogs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind advanced config events
|
||||
*/
|
||||
function bindAdvancedEvents() {
|
||||
const saveAdvanced = document.getElementById('save-advanced-config');
|
||||
if (saveAdvanced) {
|
||||
saveAdvanced.addEventListener('click', AniWorld.AdvancedConfig.save);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind main configuration events
|
||||
*/
|
||||
function bindMainEvents() {
|
||||
const createBackup = document.getElementById('create-config-backup');
|
||||
if (createBackup) {
|
||||
createBackup.addEventListener('click', AniWorld.MainConfig.createBackup);
|
||||
}
|
||||
|
||||
const viewBackups = document.getElementById('view-config-backups');
|
||||
if (viewBackups) {
|
||||
viewBackups.addEventListener('click', AniWorld.MainConfig.viewBackups);
|
||||
}
|
||||
|
||||
const exportConfig = document.getElementById('export-config');
|
||||
if (exportConfig) {
|
||||
exportConfig.addEventListener('click', AniWorld.MainConfig.exportConfig);
|
||||
}
|
||||
|
||||
const validateConfig = document.getElementById('validate-config');
|
||||
if (validateConfig) {
|
||||
validateConfig.addEventListener('click', AniWorld.MainConfig.validateConfig);
|
||||
}
|
||||
|
||||
const resetConfig = document.getElementById('reset-config');
|
||||
if (resetConfig) {
|
||||
resetConfig.addEventListener('click', handleResetConfig);
|
||||
}
|
||||
|
||||
const saveMain = document.getElementById('save-main-config');
|
||||
if (saveMain) {
|
||||
saveMain.addEventListener('click', AniWorld.MainConfig.save);
|
||||
}
|
||||
|
||||
const resetMain = document.getElementById('reset-main-config');
|
||||
if (resetMain) {
|
||||
resetMain.addEventListener('click', AniWorld.MainConfig.reset);
|
||||
}
|
||||
|
||||
const testConnection = document.getElementById('test-connection');
|
||||
if (testConnection) {
|
||||
testConnection.addEventListener('click', AniWorld.MainConfig.testConnection);
|
||||
}
|
||||
|
||||
const browseDirectory = document.getElementById('browse-directory');
|
||||
if (browseDirectory) {
|
||||
browseDirectory.addEventListener('click', AniWorld.MainConfig.browseDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reset config with modal refresh
|
||||
*/
|
||||
async function handleResetConfig() {
|
||||
const success = await AniWorld.MainConfig.resetAllConfig();
|
||||
if (success) {
|
||||
setTimeout(function() {
|
||||
hideConfigModal();
|
||||
showConfigModal();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the configuration modal
|
||||
*/
|
||||
async function showConfigModal() {
|
||||
const modal = document.getElementById('config-modal');
|
||||
|
||||
try {
|
||||
// Load current status
|
||||
const response = await AniWorld.ApiClient.get(API.ANIME_STATUS);
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('anime-directory-input').value = data.directory || '';
|
||||
document.getElementById('series-count-input').value = data.series_count || '0';
|
||||
|
||||
// Load all configuration sections
|
||||
await AniWorld.SchedulerConfig.load();
|
||||
await AniWorld.LoggingConfig.load();
|
||||
await AniWorld.AdvancedConfig.load();
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error loading configuration:', error);
|
||||
AniWorld.UI.showToast('Failed to load configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the configuration modal
|
||||
*/
|
||||
function hideConfigModal() {
|
||||
document.getElementById('config-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide status panel
|
||||
*/
|
||||
function hideStatus() {
|
||||
document.getElementById('status-panel').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
showConfigModal: showConfigModal,
|
||||
hideConfigModal: hideConfigModal,
|
||||
hideStatus: hideStatus
|
||||
};
|
||||
})();
|
||||
278
src/server/web/static/js/index/logging-config.js
Normal file
278
src/server/web/static/js/index/logging-config.js
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AniWorld - Logging Config Module
|
||||
*
|
||||
* Handles logging configuration, log file management, and log viewing.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.LoggingConfig = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Load logging configuration
|
||||
*/
|
||||
async function load() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.LOGGING_CONFIG);
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const config = data.config;
|
||||
|
||||
// Set form values
|
||||
document.getElementById('log-level').value = config.log_level || 'INFO';
|
||||
document.getElementById('enable-console-logging').checked = config.enable_console_logging !== false;
|
||||
document.getElementById('enable-console-progress').checked = config.enable_console_progress === true;
|
||||
document.getElementById('enable-fail2ban-logging').checked = config.enable_fail2ban_logging !== false;
|
||||
|
||||
// Load log files
|
||||
await loadLogFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading logging config:', error);
|
||||
AniWorld.UI.showToast('Failed to load logging configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load log files list
|
||||
*/
|
||||
async function loadLogFiles() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.LOGGING_FILES);
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const container = document.getElementById('log-files-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (data.files.length === 0) {
|
||||
container.innerHTML = '<div class="log-file-item"><span>No log files found</span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.files.forEach(function(file) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'log-file-item';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'log-file-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'log-file-name';
|
||||
name.textContent = file.name;
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'log-file-details';
|
||||
details.textContent = 'Size: ' + file.size_mb + ' MB • Modified: ' + new Date(file.modified).toLocaleDateString();
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(details);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'log-file-actions';
|
||||
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.className = 'btn btn-xs btn-secondary';
|
||||
downloadBtn.innerHTML = '<i class="fas fa-download"></i>';
|
||||
downloadBtn.title = 'Download';
|
||||
downloadBtn.onclick = function() { downloadLogFile(file.name); };
|
||||
|
||||
const viewBtn = document.createElement('button');
|
||||
viewBtn.className = 'btn btn-xs btn-secondary';
|
||||
viewBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
||||
viewBtn.title = 'View Last 100 Lines';
|
||||
viewBtn.onclick = function() { viewLogFile(file.name); };
|
||||
|
||||
actions.appendChild(downloadBtn);
|
||||
actions.appendChild(viewBtn);
|
||||
|
||||
item.appendChild(info);
|
||||
item.appendChild(actions);
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading log files:', error);
|
||||
AniWorld.UI.showToast('Failed to load log files', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save logging configuration
|
||||
*/
|
||||
async function save() {
|
||||
try {
|
||||
const config = {
|
||||
log_level: document.getElementById('log-level').value,
|
||||
enable_console_logging: document.getElementById('enable-console-logging').checked,
|
||||
enable_console_progress: document.getElementById('enable-console-progress').checked,
|
||||
enable_fail2ban_logging: document.getElementById('enable-fail2ban-logging').checked
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.request(API.LOGGING_CONFIG, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Logging configuration saved successfully', 'success');
|
||||
await load();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to save logging config: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving logging config:', error);
|
||||
AniWorld.UI.showToast('Failed to save logging configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test logging functionality
|
||||
*/
|
||||
async function testLogging() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.LOGGING_TEST, {});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Test messages logged successfully', 'success');
|
||||
setTimeout(loadLogFiles, 1000);
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to test logging: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing logging:', error);
|
||||
AniWorld.UI.showToast('Failed to test logging', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old log files
|
||||
*/
|
||||
async function cleanupLogs() {
|
||||
const days = prompt('Delete log files older than how many days?', '30');
|
||||
if (!days || isNaN(days) || days < 1) {
|
||||
AniWorld.UI.showToast('Invalid number of days', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(API.LOGGING_CLEANUP, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days: parseInt(days) })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast(data.message, 'success');
|
||||
await loadLogFiles();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to cleanup logs: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up logs:', error);
|
||||
AniWorld.UI.showToast('Failed to cleanup logs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a log file
|
||||
*/
|
||||
function downloadLogFile(filename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = '/api/logging/files/' + encodeURIComponent(filename) + '/download';
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* View a log file's last lines
|
||||
*/
|
||||
async function viewLogFile(filename) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get('/api/logging/files/' + encodeURIComponent(filename) + '/tail?lines=100');
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Create modal to show log content
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.style.display = 'block';
|
||||
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.className = 'modal-content';
|
||||
modalContent.style.maxWidth = '80%';
|
||||
modalContent.style.maxHeight = '80%';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.innerHTML = '<h3>Log File: ' + filename + '</h3><p>Showing last ' + data.showing_lines + ' of ' + data.total_lines + ' lines</p>';
|
||||
|
||||
const content = document.createElement('pre');
|
||||
content.style.maxHeight = '60vh';
|
||||
content.style.overflow = 'auto';
|
||||
content.style.backgroundColor = '#f5f5f5';
|
||||
content.style.padding = '10px';
|
||||
content.style.fontSize = '12px';
|
||||
content.textContent = data.lines.join('\n');
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.className = 'btn btn-secondary';
|
||||
closeBtn.onclick = function() { document.body.removeChild(modal); };
|
||||
|
||||
modalContent.appendChild(header);
|
||||
modalContent.appendChild(content);
|
||||
modalContent.appendChild(closeBtn);
|
||||
modal.appendChild(modalContent);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Close on background click
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to view log file: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error viewing log file:', error);
|
||||
AniWorld.UI.showToast('Failed to view log file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
load: load,
|
||||
loadLogFiles: loadLogFiles,
|
||||
save: save,
|
||||
testLogging: testLogging,
|
||||
cleanupLogs: cleanupLogs,
|
||||
downloadLogFile: downloadLogFile,
|
||||
viewLogFile: viewLogFile
|
||||
};
|
||||
})();
|
||||
294
src/server/web/static/js/index/main-config.js
Normal file
294
src/server/web/static/js/index/main-config.js
Normal file
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* AniWorld - Main Config Module
|
||||
*
|
||||
* Handles main configuration (directory, connection) and config management
|
||||
* (backup, export, validate, reset).
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.MainConfig = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Save main configuration
|
||||
*/
|
||||
async function save() {
|
||||
try {
|
||||
const animeDirectory = document.getElementById('anime-directory-input').value.trim();
|
||||
|
||||
if (!animeDirectory) {
|
||||
AniWorld.UI.showToast('Please enter an anime directory path', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.CONFIG_DIRECTORY, {
|
||||
directory: animeDirectory
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Main configuration saved successfully', 'success');
|
||||
await refreshStatus();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to save configuration: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving main config:', error);
|
||||
AniWorld.UI.showToast('Failed to save main configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset main configuration
|
||||
*/
|
||||
function reset() {
|
||||
if (confirm('Are you sure you want to reset the main configuration? This will clear the anime directory.')) {
|
||||
document.getElementById('anime-directory-input').value = '';
|
||||
document.getElementById('series-count-input').value = '0';
|
||||
AniWorld.UI.showToast('Main configuration reset', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test network connection
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
AniWorld.UI.showToast('Testing connection...', 'info');
|
||||
|
||||
const response = await AniWorld.ApiClient.get(API.DIAGNOSTICS_NETWORK);
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
const networkStatus = data.data;
|
||||
const connectionDiv = document.getElementById('connection-status-display');
|
||||
const statusIndicator = connectionDiv.querySelector('.status-indicator');
|
||||
const statusText = connectionDiv.querySelector('.status-text');
|
||||
|
||||
if (networkStatus.aniworld_reachable) {
|
||||
statusIndicator.className = 'status-indicator connected';
|
||||
statusText.textContent = 'Connected';
|
||||
AniWorld.UI.showToast('Connection test successful', 'success');
|
||||
} else {
|
||||
statusIndicator.className = 'status-indicator disconnected';
|
||||
statusText.textContent = 'Disconnected';
|
||||
AniWorld.UI.showToast('Connection test failed', 'error');
|
||||
}
|
||||
} else {
|
||||
AniWorld.UI.showToast('Connection test failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing connection:', error);
|
||||
AniWorld.UI.showToast('Connection test failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse for directory
|
||||
*/
|
||||
function browseDirectory() {
|
||||
const currentPath = document.getElementById('anime-directory-input').value;
|
||||
const newPath = prompt('Enter the anime directory path:', currentPath);
|
||||
|
||||
if (newPath !== null && newPath.trim() !== '') {
|
||||
document.getElementById('anime-directory-input').value = newPath.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status display
|
||||
*/
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.ANIME_STATUS);
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('anime-directory-input').value = data.directory || '';
|
||||
document.getElementById('series-count-input').value = data.series_count || '0';
|
||||
} catch (error) {
|
||||
console.error('Error refreshing status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create configuration backup
|
||||
*/
|
||||
async function createBackup() {
|
||||
const backupName = prompt('Enter backup name (optional):');
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(API.CONFIG_BACKUP, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: backupName || '' })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Backup created: ' + data.filename, 'success');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to create backup: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating backup:', error);
|
||||
AniWorld.UI.showToast('Failed to create backup', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View configuration backups
|
||||
*/
|
||||
async function viewBackups() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.CONFIG_BACKUPS);
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showBackupsModal(data.backups);
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to load backups: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading backups:', error);
|
||||
AniWorld.UI.showToast('Failed to load backups', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show backups modal
|
||||
*/
|
||||
function showBackupsModal(backups) {
|
||||
// Implementation for showing backups modal
|
||||
console.log('Backups:', backups);
|
||||
AniWorld.UI.showToast('Found ' + backups.length + ' backup(s)', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration
|
||||
*/
|
||||
function exportConfig() {
|
||||
AniWorld.UI.showToast('Export configuration feature coming soon', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
async function validateConfig() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(API.CONFIG_VALIDATE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showValidationResults(data.validation);
|
||||
} else {
|
||||
AniWorld.UI.showToast('Validation failed: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating config:', error);
|
||||
AniWorld.UI.showToast('Failed to validate configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation results
|
||||
*/
|
||||
function showValidationResults(validation) {
|
||||
const container = document.getElementById('validation-results');
|
||||
container.innerHTML = '';
|
||||
container.classList.remove('hidden');
|
||||
|
||||
if (validation.valid) {
|
||||
const success = document.createElement('div');
|
||||
success.className = 'validation-success';
|
||||
success.innerHTML = '<i class="fas fa-check-circle"></i> Configuration is valid!';
|
||||
container.appendChild(success);
|
||||
} else {
|
||||
const header = document.createElement('div');
|
||||
header.innerHTML = '<strong>Validation Issues Found:</strong>';
|
||||
container.appendChild(header);
|
||||
}
|
||||
|
||||
// Show errors
|
||||
validation.errors.forEach(function(error) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'validation-error';
|
||||
errorDiv.innerHTML = '<i class="fas fa-times-circle"></i> Error: ' + error;
|
||||
container.appendChild(errorDiv);
|
||||
});
|
||||
|
||||
// Show warnings
|
||||
validation.warnings.forEach(function(warning) {
|
||||
const warningDiv = document.createElement('div');
|
||||
warningDiv.className = 'validation-warning';
|
||||
warningDiv.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Warning: ' + warning;
|
||||
container.appendChild(warningDiv);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all configuration to defaults
|
||||
*/
|
||||
async function resetAllConfig() {
|
||||
if (!confirm('Are you sure you want to reset all configuration to defaults? This cannot be undone (except by restoring a backup).')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(API.CONFIG_RESET, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preserve_security: true })
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Configuration reset to defaults', 'success');
|
||||
// Notify caller to reload config modal
|
||||
return true;
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to reset config: ' + data.error, 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resetting config:', error);
|
||||
AniWorld.UI.showToast('Failed to reset configuration', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
save: save,
|
||||
reset: reset,
|
||||
testConnection: testConnection,
|
||||
browseDirectory: browseDirectory,
|
||||
refreshStatus: refreshStatus,
|
||||
createBackup: createBackup,
|
||||
viewBackups: viewBackups,
|
||||
exportConfig: exportConfig,
|
||||
validateConfig: validateConfig,
|
||||
resetAllConfig: resetAllConfig
|
||||
};
|
||||
})();
|
||||
439
src/server/web/static/js/index/scan-manager.js
Normal file
439
src/server/web/static/js/index/scan-manager.js
Normal file
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* AniWorld - Scan Manager Module
|
||||
*
|
||||
* Handles library scanning and progress overlay.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ScanManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
const DEFAULTS = AniWorld.Constants.DEFAULTS;
|
||||
|
||||
// State
|
||||
let scanTotalItems = 0;
|
||||
let lastScanData = null;
|
||||
|
||||
/**
|
||||
* Initialize the scan manager
|
||||
*/
|
||||
function init() {
|
||||
bindEvents();
|
||||
// Check scan status on page load
|
||||
checkActiveScanStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI events
|
||||
*/
|
||||
function bindEvents() {
|
||||
const rescanBtn = document.getElementById('rescan-btn');
|
||||
if (rescanBtn) {
|
||||
rescanBtn.addEventListener('click', rescanSeries);
|
||||
}
|
||||
|
||||
// Click on rescan status indicator to reopen scan overlay
|
||||
const rescanStatus = document.getElementById('rescan-status');
|
||||
if (rescanStatus) {
|
||||
rescanStatus.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
console.log('Rescan status clicked');
|
||||
reopenScanOverlay();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a rescan of the series directory
|
||||
*/
|
||||
async function rescanSeries() {
|
||||
try {
|
||||
// Show the overlay immediately before making the API call
|
||||
showScanProgressOverlay({
|
||||
directory: 'Starting scan...',
|
||||
total_items: 0
|
||||
});
|
||||
updateProcessStatus('rescan', true);
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.ANIME_RESCAN, {});
|
||||
|
||||
if (!response) {
|
||||
removeScanProgressOverlay();
|
||||
updateProcessStatus('rescan', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Debug logging
|
||||
console.log('Rescan response:', data);
|
||||
|
||||
// Note: The scan progress will be updated via WebSocket events
|
||||
// The overlay will be closed when scan_completed is received
|
||||
if (data.success !== true) {
|
||||
removeScanProgressOverlay();
|
||||
updateProcessStatus('rescan', false);
|
||||
AniWorld.UI.showToast('Rescan error: ' + data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Rescan error:', error);
|
||||
removeScanProgressOverlay();
|
||||
updateProcessStatus('rescan', false);
|
||||
AniWorld.UI.showToast('Failed to start rescan', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the scan progress overlay
|
||||
* @param {Object} data - Scan started event data
|
||||
*/
|
||||
function showScanProgressOverlay(data) {
|
||||
// Remove existing overlay if present
|
||||
removeScanProgressOverlay();
|
||||
|
||||
// Store total items for progress calculation
|
||||
scanTotalItems = data?.total_items || 0;
|
||||
|
||||
// Store last scan data for reopening
|
||||
lastScanData = data;
|
||||
|
||||
// Create overlay element
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'scan-progress-overlay';
|
||||
overlay.className = 'scan-progress-overlay';
|
||||
|
||||
const totalDisplay = scanTotalItems > 0 ? scanTotalItems : '...';
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="scan-progress-container">' +
|
||||
'<div class="scan-progress-header">' +
|
||||
'<h3>' +
|
||||
'<span class="scan-progress-spinner"></span>' +
|
||||
'<i class="fas fa-check-circle scan-completed-icon"></i>' +
|
||||
'<span class="scan-title-text">Scanning Library</span>' +
|
||||
'</h3>' +
|
||||
'</div>' +
|
||||
'<div class="scan-progress-bar-container">' +
|
||||
'<div class="scan-progress-bar" id="scan-progress-bar" style="width: 0%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="scan-progress-text" id="scan-progress-text">' +
|
||||
'<span id="scan-current-count">0</span> / <span id="scan-total-count">' + totalDisplay + '</span> directories' +
|
||||
'</div>' +
|
||||
'<div class="scan-progress-stats">' +
|
||||
'<div class="scan-stat">' +
|
||||
'<span class="scan-stat-value" id="scan-directories-count">0</span>' +
|
||||
'<span class="scan-stat-label">Scanned</span>' +
|
||||
'</div>' +
|
||||
'<div class="scan-stat">' +
|
||||
'<span class="scan-stat-value" id="scan-files-count">0</span>' +
|
||||
'<span class="scan-stat-label">Series Found</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="scan-current-directory" id="scan-current-directory">' +
|
||||
'<span class="scan-current-directory-label">Current:</span>' +
|
||||
'<span id="scan-current-path">' + AniWorld.UI.escapeHtml(data?.directory || 'Initializing...') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="scan-elapsed-time hidden" id="scan-elapsed-time">' +
|
||||
'<i class="fas fa-clock"></i>' +
|
||||
'<span id="scan-elapsed-value">0.0s</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Add click-outside-to-close handler
|
||||
overlay.addEventListener('click', function(e) {
|
||||
// Only close if clicking the overlay background, not the container
|
||||
if (e.target === overlay) {
|
||||
removeScanProgressOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger animation by adding visible class after a brief delay
|
||||
requestAnimationFrame(function() {
|
||||
overlay.classList.add('visible');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scan progress overlay
|
||||
* @param {Object} data - Scan progress event data
|
||||
*/
|
||||
function updateScanProgressOverlay(data) {
|
||||
const overlay = document.getElementById('scan-progress-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
// Update total items if provided
|
||||
if (data.total_items && data.total_items > 0) {
|
||||
scanTotalItems = data.total_items;
|
||||
const totalCount = document.getElementById('scan-total-count');
|
||||
if (totalCount) {
|
||||
totalCount.textContent = scanTotalItems;
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
if (progressBar && scanTotalItems > 0 && data.directories_scanned !== undefined) {
|
||||
const percentage = Math.min(100, (data.directories_scanned / scanTotalItems) * 100);
|
||||
progressBar.style.width = percentage + '%';
|
||||
}
|
||||
|
||||
// Update current/total count display
|
||||
const currentCount = document.getElementById('scan-current-count');
|
||||
if (currentCount && data.directories_scanned !== undefined) {
|
||||
currentCount.textContent = data.directories_scanned;
|
||||
}
|
||||
|
||||
// Update directories count
|
||||
const dirCount = document.getElementById('scan-directories-count');
|
||||
if (dirCount && data.directories_scanned !== undefined) {
|
||||
dirCount.textContent = data.directories_scanned;
|
||||
}
|
||||
|
||||
// Update files/series count
|
||||
const filesCount = document.getElementById('scan-files-count');
|
||||
if (filesCount && data.files_found !== undefined) {
|
||||
filesCount.textContent = data.files_found;
|
||||
}
|
||||
|
||||
// Update current directory (truncate if too long)
|
||||
const currentPath = document.getElementById('scan-current-path');
|
||||
if (currentPath && data.current_directory) {
|
||||
const maxLength = 50;
|
||||
let displayPath = data.current_directory;
|
||||
if (displayPath.length > maxLength) {
|
||||
displayPath = '...' + displayPath.slice(-maxLength + 3);
|
||||
}
|
||||
currentPath.textContent = displayPath;
|
||||
currentPath.title = data.current_directory;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the scan progress overlay with completion summary
|
||||
* @param {Object} data - Scan completed event data
|
||||
*/
|
||||
function hideScanProgressOverlay(data) {
|
||||
const overlay = document.getElementById('scan-progress-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
const container = overlay.querySelector('.scan-progress-container');
|
||||
if (container) {
|
||||
container.classList.add('completed');
|
||||
}
|
||||
|
||||
// Update title
|
||||
const titleText = overlay.querySelector('.scan-title-text');
|
||||
if (titleText) {
|
||||
titleText.textContent = 'Scan Complete';
|
||||
}
|
||||
|
||||
// Complete the progress bar
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = '100%';
|
||||
}
|
||||
|
||||
// Update final stats
|
||||
if (data) {
|
||||
const dirCount = document.getElementById('scan-directories-count');
|
||||
if (dirCount && data.total_directories !== undefined) {
|
||||
dirCount.textContent = data.total_directories;
|
||||
}
|
||||
|
||||
const filesCount = document.getElementById('scan-files-count');
|
||||
if (filesCount && data.total_files !== undefined) {
|
||||
filesCount.textContent = data.total_files;
|
||||
}
|
||||
|
||||
// Update progress text to show final count
|
||||
const currentCount = document.getElementById('scan-current-count');
|
||||
const totalCount = document.getElementById('scan-total-count');
|
||||
if (currentCount && data.total_directories !== undefined) {
|
||||
currentCount.textContent = data.total_directories;
|
||||
}
|
||||
if (totalCount && data.total_directories !== undefined) {
|
||||
totalCount.textContent = data.total_directories;
|
||||
}
|
||||
|
||||
// Show elapsed time
|
||||
const elapsedTimeEl = document.getElementById('scan-elapsed-time');
|
||||
const elapsedValueEl = document.getElementById('scan-elapsed-value');
|
||||
if (elapsedTimeEl && elapsedValueEl && data.elapsed_seconds !== undefined) {
|
||||
elapsedValueEl.textContent = data.elapsed_seconds.toFixed(1) + 's';
|
||||
elapsedTimeEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update current directory to show completion message
|
||||
const currentPath = document.getElementById('scan-current-path');
|
||||
if (currentPath) {
|
||||
currentPath.textContent = 'Scan finished successfully';
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-dismiss after 3 seconds
|
||||
setTimeout(function() {
|
||||
removeScanProgressOverlay();
|
||||
}, DEFAULTS.SCAN_AUTO_DISMISS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the scan progress overlay from the DOM
|
||||
*/
|
||||
function removeScanProgressOverlay() {
|
||||
const overlay = document.getElementById('scan-progress-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
// Wait for fade out animation before removing
|
||||
setTimeout(function() {
|
||||
if (overlay.parentElement) {
|
||||
overlay.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the scan progress overlay if a scan is in progress
|
||||
*/
|
||||
async function reopenScanOverlay() {
|
||||
// Check if overlay already exists
|
||||
const existingOverlay = document.getElementById('scan-progress-overlay');
|
||||
if (existingOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scan is running via API
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.ANIME_SCAN_STATUS);
|
||||
if (!response || !response.ok) {
|
||||
console.log('Could not fetch scan status');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Scan status for reopen:', data);
|
||||
|
||||
if (data.is_scanning) {
|
||||
// A scan is in progress, show the overlay
|
||||
showScanProgressOverlay({
|
||||
directory: data.directory,
|
||||
total_items: data.total_items
|
||||
});
|
||||
|
||||
// Update with current progress
|
||||
updateScanProgressOverlay({
|
||||
directories_scanned: data.directories_scanned,
|
||||
files_found: data.directories_scanned,
|
||||
current_directory: data.current_directory,
|
||||
total_items: data.total_items
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking scan status for reopen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scan is currently in progress
|
||||
*/
|
||||
async function checkActiveScanStatus() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.ANIME_SCAN_STATUS);
|
||||
if (!response || !response.ok) {
|
||||
console.log('Could not fetch scan status, response:', response?.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Scan status check result:', data);
|
||||
|
||||
if (data.is_scanning) {
|
||||
console.log('Scan is active, updating UI indicators');
|
||||
|
||||
// Update the process status indicator
|
||||
updateProcessStatus('rescan', true);
|
||||
|
||||
// Show the overlay
|
||||
showScanProgressOverlay({
|
||||
directory: data.directory,
|
||||
total_items: data.total_items
|
||||
});
|
||||
|
||||
// Update with current progress
|
||||
updateScanProgressOverlay({
|
||||
directories_scanned: data.directories_scanned,
|
||||
files_found: data.directories_scanned,
|
||||
current_directory: data.current_directory,
|
||||
total_items: data.total_items
|
||||
});
|
||||
} else {
|
||||
console.log('No active scan detected');
|
||||
updateProcessStatus('rescan', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking scan status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update process status indicator
|
||||
* @param {string} processName - Process name (e.g., 'rescan', 'download')
|
||||
* @param {boolean} isRunning - Whether the process is running
|
||||
* @param {boolean} hasError - Whether there's an error
|
||||
*/
|
||||
function updateProcessStatus(processName, isRunning, hasError) {
|
||||
hasError = hasError || false;
|
||||
const statusElement = document.getElementById(processName + '-status');
|
||||
if (!statusElement) {
|
||||
console.warn('Process status element not found: ' + processName + '-status');
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDot = statusElement.querySelector('.status-dot');
|
||||
if (!statusDot) {
|
||||
console.warn('Status dot not found in ' + processName + '-status element');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all status classes
|
||||
statusDot.classList.remove('idle', 'running', 'error');
|
||||
statusElement.classList.remove('running', 'error', 'idle');
|
||||
|
||||
// Capitalize process name for display
|
||||
const displayName = processName.charAt(0).toUpperCase() + processName.slice(1);
|
||||
|
||||
if (hasError) {
|
||||
statusDot.classList.add('error');
|
||||
statusElement.classList.add('error');
|
||||
statusElement.title = displayName + ' error - click for details';
|
||||
} else if (isRunning) {
|
||||
statusDot.classList.add('running');
|
||||
statusElement.classList.add('running');
|
||||
statusElement.title = displayName + ' is running...';
|
||||
} else {
|
||||
statusDot.classList.add('idle');
|
||||
statusElement.classList.add('idle');
|
||||
statusElement.title = displayName + ' is idle';
|
||||
}
|
||||
|
||||
console.log('Process status updated: ' + processName + ' = ' + (isRunning ? 'running' : (hasError ? 'error' : 'idle')));
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
rescanSeries: rescanSeries,
|
||||
showScanProgressOverlay: showScanProgressOverlay,
|
||||
updateScanProgressOverlay: updateScanProgressOverlay,
|
||||
hideScanProgressOverlay: hideScanProgressOverlay,
|
||||
removeScanProgressOverlay: removeScanProgressOverlay,
|
||||
reopenScanOverlay: reopenScanOverlay,
|
||||
checkActiveScanStatus: checkActiveScanStatus,
|
||||
updateProcessStatus: updateProcessStatus
|
||||
};
|
||||
})();
|
||||
124
src/server/web/static/js/index/scheduler-config.js
Normal file
124
src/server/web/static/js/index/scheduler-config.js
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* AniWorld - Scheduler Config Module
|
||||
*
|
||||
* Handles scheduler configuration and scheduled rescan settings.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.SchedulerConfig = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Load scheduler configuration
|
||||
*/
|
||||
async function load() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.SCHEDULER_CONFIG);
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const config = data.config;
|
||||
|
||||
// Update UI elements
|
||||
document.getElementById('scheduled-rescan-enabled').checked = config.enabled;
|
||||
document.getElementById('scheduled-rescan-time').value = config.time || '03:00';
|
||||
document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan;
|
||||
|
||||
// Update status display
|
||||
document.getElementById('next-rescan-time').textContent =
|
||||
config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled';
|
||||
document.getElementById('last-rescan-time').textContent =
|
||||
config.last_run ? new Date(config.last_run).toLocaleString() : 'Never';
|
||||
|
||||
const statusBadge = document.getElementById('scheduler-running-status');
|
||||
statusBadge.textContent = config.is_running ? 'Running' : 'Stopped';
|
||||
statusBadge.className = 'info-value status-badge ' + (config.is_running ? 'running' : 'stopped');
|
||||
|
||||
// Enable/disable time input based on checkbox
|
||||
toggleTimeInput();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading scheduler config:', error);
|
||||
AniWorld.UI.showToast('Failed to load scheduler configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save scheduler configuration
|
||||
*/
|
||||
async function save() {
|
||||
try {
|
||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||
const time = document.getElementById('scheduled-rescan-time').value;
|
||||
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, {
|
||||
enabled: enabled,
|
||||
time: time,
|
||||
auto_download_after_rescan: autoDownload
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success');
|
||||
await load();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to save config: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving scheduler config:', error);
|
||||
AniWorld.UI.showToast('Failed to save scheduler configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test scheduled rescan
|
||||
*/
|
||||
async function testRescan() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_TRIGGER, {});
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
AniWorld.UI.showToast('Test rescan triggered successfully', 'success');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to trigger test rescan: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering test rescan:', error);
|
||||
AniWorld.UI.showToast('Failed to trigger test rescan', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle scheduler time input visibility
|
||||
*/
|
||||
function toggleTimeInput() {
|
||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||
const timeConfig = document.getElementById('rescan-time-config');
|
||||
|
||||
if (enabled) {
|
||||
timeConfig.classList.add('enabled');
|
||||
} else {
|
||||
timeConfig.classList.remove('enabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
load: load,
|
||||
save: save,
|
||||
testRescan: testRescan,
|
||||
toggleTimeInput: toggleTimeInput
|
||||
};
|
||||
})();
|
||||
156
src/server/web/static/js/index/search.js
Normal file
156
src/server/web/static/js/index/search.js
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* AniWorld - Search Module
|
||||
*
|
||||
* Handles anime search functionality and result display.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js, series-manager.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.Search = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Initialize the search module
|
||||
*/
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI events
|
||||
*/
|
||||
function bindEvents() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchBtn = document.getElementById('search-btn');
|
||||
const clearSearch = document.getElementById('clear-search');
|
||||
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', performSearch);
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearSearch) {
|
||||
clearSearch.addEventListener('click', function() {
|
||||
if (searchInput) searchInput.value = '';
|
||||
hideSearchResults();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform anime search
|
||||
*/
|
||||
async function performSearch() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const query = searchInput ? searchInput.value.trim() : '';
|
||||
|
||||
if (!query) {
|
||||
AniWorld.UI.showToast('Please enter a search term', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AniWorld.UI.showLoading();
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.ANIME_SEARCH, { query: query });
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
// Check if response is a direct array (new format) or wrapped object (legacy)
|
||||
if (Array.isArray(data)) {
|
||||
displaySearchResults(data);
|
||||
} else if (data.status === 'success') {
|
||||
displaySearchResults(data.results);
|
||||
} else {
|
||||
AniWorld.UI.showToast('Search error: ' + (data.message || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
AniWorld.UI.showToast('Search failed', 'error');
|
||||
} finally {
|
||||
AniWorld.UI.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
* @param {Array} results - Search results array
|
||||
*/
|
||||
function displaySearchResults(results) {
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
const resultsList = document.getElementById('search-results-list');
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsContainer.classList.add('hidden');
|
||||
AniWorld.UI.showToast('No search results found', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
resultsList.innerHTML = results.map(function(result) {
|
||||
const displayName = AniWorld.UI.getDisplayName(result);
|
||||
return '<div class="search-result-item">' +
|
||||
'<span class="search-result-name">' + AniWorld.UI.escapeHtml(displayName) + '</span>' +
|
||||
'<button class="btn btn-small btn-primary" ' +
|
||||
'onclick="AniWorld.Search.addSeries(\'' + AniWorld.UI.escapeHtml(result.link) + '\', \'' +
|
||||
AniWorld.UI.escapeHtml(displayName) + '\')">' +
|
||||
'<i class="fas fa-plus"></i> Add' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
resultsContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide search results
|
||||
*/
|
||||
function hideSearchResults() {
|
||||
document.getElementById('search-results').classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a series from search results
|
||||
* @param {string} link - Series link
|
||||
* @param {string} name - Series name
|
||||
*/
|
||||
async function addSeries(link, name) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.ANIME_ADD, { link: link, name: name });
|
||||
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
AniWorld.UI.showToast(data.message, 'success');
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
hideSearchResults();
|
||||
document.getElementById('search-input').value = '';
|
||||
} else {
|
||||
AniWorld.UI.showToast('Error adding series: ' + data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding series:', error);
|
||||
AniWorld.UI.showToast('Failed to add series', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
performSearch: performSearch,
|
||||
hideSearchResults: hideSearchResults,
|
||||
addSeries: addSeries
|
||||
};
|
||||
})();
|
||||
296
src/server/web/static/js/index/selection-manager.js
Normal file
296
src/server/web/static/js/index/selection-manager.js
Normal file
@ -0,0 +1,296 @@
|
||||
/**
|
||||
* AniWorld - Selection Manager Module
|
||||
*
|
||||
* Handles series selection for downloads.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js, series-manager.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.SelectionManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
// State
|
||||
let selectedSeries = new Set();
|
||||
|
||||
/**
|
||||
* Initialize the selection manager
|
||||
*/
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI events
|
||||
*/
|
||||
function bindEvents() {
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', toggleSelectAll);
|
||||
}
|
||||
|
||||
const downloadBtn = document.getElementById('download-selected');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', downloadSelected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle series selection
|
||||
* @param {string} key - Series key
|
||||
* @param {boolean} selected - Whether to select or deselect
|
||||
*/
|
||||
function toggleSerieSelection(key, selected) {
|
||||
// Only allow selection of series with missing episodes
|
||||
const serie = AniWorld.SeriesManager.findByKey(key);
|
||||
if (!serie || serie.missing_episodes === 0) {
|
||||
// Uncheck the checkbox if it was checked for a complete series
|
||||
const checkbox = document.querySelector('input[data-key="' + key + '"]');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
selectedSeries.add(key);
|
||||
} else {
|
||||
selectedSeries.delete(key);
|
||||
}
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a series is selected
|
||||
* @param {string} key - Series key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSelected(key) {
|
||||
return selectedSeries.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selection UI (buttons and card styles)
|
||||
*/
|
||||
function updateSelectionUI() {
|
||||
const downloadBtn = document.getElementById('download-selected');
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
|
||||
// Get series that can be selected (have missing episodes)
|
||||
const selectableSeriesData = AniWorld.SeriesManager.getFilteredSeriesData().length > 0 ?
|
||||
AniWorld.SeriesManager.getFilteredSeriesData() : AniWorld.SeriesManager.getSeriesData();
|
||||
const selectableSeries = selectableSeriesData.filter(function(serie) {
|
||||
return serie.missing_episodes > 0;
|
||||
});
|
||||
const selectableKeys = selectableSeries.map(function(serie) {
|
||||
return serie.key;
|
||||
});
|
||||
|
||||
downloadBtn.disabled = selectedSeries.size === 0;
|
||||
|
||||
const allSelectableSelected = selectableKeys.every(function(key) {
|
||||
return selectedSeries.has(key);
|
||||
});
|
||||
|
||||
if (selectedSeries.size === 0) {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||
} else if (allSelectableSelected && selectableKeys.length > 0) {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>';
|
||||
} else {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||
}
|
||||
|
||||
// Update card appearances
|
||||
document.querySelectorAll('.series-card').forEach(function(card) {
|
||||
const key = card.dataset.key;
|
||||
const isSelectedCard = selectedSeries.has(key);
|
||||
card.classList.toggle('selected', isSelectedCard);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle select all / deselect all
|
||||
*/
|
||||
function toggleSelectAll() {
|
||||
// Get series that can be selected (have missing episodes)
|
||||
const selectableSeriesData = AniWorld.SeriesManager.getFilteredSeriesData().length > 0 ?
|
||||
AniWorld.SeriesManager.getFilteredSeriesData() : AniWorld.SeriesManager.getSeriesData();
|
||||
const selectableSeries = selectableSeriesData.filter(function(serie) {
|
||||
return serie.missing_episodes > 0;
|
||||
});
|
||||
const selectableKeys = selectableSeries.map(function(serie) {
|
||||
return serie.key;
|
||||
});
|
||||
|
||||
const allSelectableSelected = selectableKeys.every(function(key) {
|
||||
return selectedSeries.has(key);
|
||||
});
|
||||
|
||||
if (allSelectableSelected && selectedSeries.size > 0) {
|
||||
// Deselect all selectable series
|
||||
selectableKeys.forEach(function(key) {
|
||||
selectedSeries.delete(key);
|
||||
});
|
||||
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
} else {
|
||||
// Select all selectable series
|
||||
selectableKeys.forEach(function(key) {
|
||||
selectedSeries.add(key);
|
||||
});
|
||||
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
function clearSelection() {
|
||||
selectedSeries.clear();
|
||||
document.querySelectorAll('.series-checkbox').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download selected series
|
||||
*/
|
||||
async function downloadSelected() {
|
||||
console.log('=== downloadSelected - Using key as primary identifier ===');
|
||||
if (selectedSeries.size === 0) {
|
||||
AniWorld.UI.showToast('No series selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedKeys = Array.from(selectedSeries);
|
||||
console.log('=== Starting download for selected series ===');
|
||||
console.log('Selected keys:', selectedKeys);
|
||||
|
||||
let totalEpisodesAdded = 0;
|
||||
let failedSeries = [];
|
||||
|
||||
// For each selected series, get its missing episodes and add to queue
|
||||
for (var i = 0; i < selectedKeys.length; i++) {
|
||||
const key = selectedKeys[i];
|
||||
const serie = AniWorld.SeriesManager.findByKey(key);
|
||||
if (!serie || !serie.episodeDict) {
|
||||
console.error('Serie not found or has no episodeDict for key:', key, serie);
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!serie.key) {
|
||||
console.error('Serie missing key:', serie);
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert episodeDict format {season: [episodes]} to episode identifiers
|
||||
const episodes = [];
|
||||
Object.entries(serie.episodeDict).forEach(function(entry) {
|
||||
const season = entry[0];
|
||||
const episodeNumbers = entry[1];
|
||||
if (Array.isArray(episodeNumbers)) {
|
||||
episodeNumbers.forEach(function(episode) {
|
||||
episodes.push({
|
||||
season: parseInt(season),
|
||||
episode: episode
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (episodes.length === 0) {
|
||||
console.log('No episodes to add for serie:', serie.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use folder name as fallback if serie name is empty
|
||||
const serieName = serie.name && serie.name.trim() ? serie.name : serie.folder;
|
||||
|
||||
// Add episodes to download queue
|
||||
const requestBody = {
|
||||
serie_id: serie.key,
|
||||
serie_folder: serie.folder,
|
||||
serie_name: serieName,
|
||||
episodes: episodes,
|
||||
priority: 'NORMAL'
|
||||
};
|
||||
console.log('Sending queue add request:', requestBody);
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.QUEUE_ADD, requestBody);
|
||||
|
||||
if (!response) {
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Queue add response:', response.status, data);
|
||||
|
||||
// Log validation errors in detail
|
||||
if (data.detail && Array.isArray(data.detail)) {
|
||||
console.error('Validation errors:', JSON.stringify(data.detail, null, 2));
|
||||
}
|
||||
|
||||
if (response.ok && data.status === 'success') {
|
||||
totalEpisodesAdded += episodes.length;
|
||||
} else {
|
||||
console.error('Failed to add to queue:', data);
|
||||
failedSeries.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Show result message
|
||||
console.log('=== Download request complete ===');
|
||||
console.log('Total episodes added:', totalEpisodesAdded);
|
||||
console.log('Failed series (keys):', failedSeries);
|
||||
|
||||
if (totalEpisodesAdded > 0) {
|
||||
const message = failedSeries.length > 0
|
||||
? 'Added ' + totalEpisodesAdded + ' episode(s) to queue (' + failedSeries.length + ' series failed)'
|
||||
: 'Added ' + totalEpisodesAdded + ' episode(s) to download queue';
|
||||
AniWorld.UI.showToast(message, 'success');
|
||||
} else {
|
||||
const errorDetails = failedSeries.length > 0
|
||||
? 'Failed series (keys): ' + failedSeries.join(', ')
|
||||
: 'No episodes were added. Check browser console for details.';
|
||||
console.error('Failed to add episodes. Details:', errorDetails);
|
||||
AniWorld.UI.showToast('Failed to add episodes to queue. Check console for details.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
AniWorld.UI.showToast('Failed to start download', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected series count
|
||||
* @returns {number}
|
||||
*/
|
||||
function getSelectionCount() {
|
||||
return selectedSeries.size;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
toggleSerieSelection: toggleSerieSelection,
|
||||
isSelected: isSelected,
|
||||
updateSelectionUI: updateSelectionUI,
|
||||
toggleSelectAll: toggleSelectAll,
|
||||
clearSelection: clearSelection,
|
||||
downloadSelected: downloadSelected,
|
||||
getSelectionCount: getSelectionCount
|
||||
};
|
||||
})();
|
||||
302
src/server/web/static/js/index/series-manager.js
Normal file
302
src/server/web/static/js/index/series-manager.js
Normal file
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* AniWorld - Series Manager Module
|
||||
*
|
||||
* Manages series data, filtering, sorting, and rendering.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.SeriesManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
// State
|
||||
let seriesData = [];
|
||||
let filteredSeriesData = [];
|
||||
let showMissingOnly = false;
|
||||
let sortAlphabetical = false;
|
||||
|
||||
/**
|
||||
* Initialize the series manager
|
||||
*/
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI events for filtering and sorting
|
||||
*/
|
||||
function bindEvents() {
|
||||
const missingOnlyBtn = document.getElementById('show-missing-only');
|
||||
if (missingOnlyBtn) {
|
||||
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter);
|
||||
}
|
||||
|
||||
const sortBtn = document.getElementById('sort-alphabetical');
|
||||
if (sortBtn) {
|
||||
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load series from API
|
||||
* @returns {Promise<Array>} Array of series data
|
||||
*/
|
||||
async function loadSeries() {
|
||||
try {
|
||||
AniWorld.UI.showLoading();
|
||||
|
||||
const response = await AniWorld.ApiClient.get(API.ANIME_LIST);
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if response has the expected format
|
||||
if (Array.isArray(data)) {
|
||||
// API returns array of AnimeSummary objects
|
||||
seriesData = data.map(function(anime) {
|
||||
// Count total missing episodes from the episode dictionary
|
||||
const episodeDict = anime.missing_episodes || {};
|
||||
const totalMissing = Object.values(episodeDict).reduce(
|
||||
function(sum, episodes) {
|
||||
return sum + (Array.isArray(episodes) ? episodes.length : 0);
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
key: anime.key,
|
||||
name: anime.name,
|
||||
site: anime.site,
|
||||
folder: anime.folder,
|
||||
episodeDict: episodeDict,
|
||||
missing_episodes: totalMissing,
|
||||
has_missing: anime.has_missing || totalMissing > 0
|
||||
};
|
||||
});
|
||||
} else if (data.status === 'success') {
|
||||
// Legacy format support
|
||||
seriesData = data.series;
|
||||
} else {
|
||||
AniWorld.UI.showToast('Error loading series: ' + (data.message || 'Unknown error'), 'error');
|
||||
return [];
|
||||
}
|
||||
|
||||
applyFiltersAndSort();
|
||||
renderSeries();
|
||||
return seriesData;
|
||||
} catch (error) {
|
||||
console.error('Error loading series:', error);
|
||||
AniWorld.UI.showToast('Failed to load series', 'error');
|
||||
return [];
|
||||
} finally {
|
||||
AniWorld.UI.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle missing episodes only filter
|
||||
*/
|
||||
function toggleMissingOnlyFilter() {
|
||||
showMissingOnly = !showMissingOnly;
|
||||
const button = document.getElementById('show-missing-only');
|
||||
|
||||
button.setAttribute('data-active', showMissingOnly);
|
||||
button.classList.toggle('active', showMissingOnly);
|
||||
|
||||
const icon = button.querySelector('i');
|
||||
const text = button.querySelector('span');
|
||||
|
||||
if (showMissingOnly) {
|
||||
icon.className = 'fas fa-filter-circle-xmark';
|
||||
text.textContent = 'Show All Series';
|
||||
} else {
|
||||
icon.className = 'fas fa-filter';
|
||||
text.textContent = 'Missing Episodes Only';
|
||||
}
|
||||
|
||||
applyFiltersAndSort();
|
||||
renderSeries();
|
||||
|
||||
// Clear selection when filter changes
|
||||
if (AniWorld.SelectionManager) {
|
||||
AniWorld.SelectionManager.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle alphabetical sorting
|
||||
*/
|
||||
function toggleAlphabeticalSort() {
|
||||
sortAlphabetical = !sortAlphabetical;
|
||||
const button = document.getElementById('sort-alphabetical');
|
||||
|
||||
button.setAttribute('data-active', sortAlphabetical);
|
||||
button.classList.toggle('active', sortAlphabetical);
|
||||
|
||||
const icon = button.querySelector('i');
|
||||
const text = button.querySelector('span');
|
||||
|
||||
if (sortAlphabetical) {
|
||||
icon.className = 'fas fa-sort-alpha-up';
|
||||
text.textContent = 'Default Sort';
|
||||
} else {
|
||||
icon.className = 'fas fa-sort-alpha-down';
|
||||
text.textContent = 'A-Z Sort';
|
||||
}
|
||||
|
||||
applyFiltersAndSort();
|
||||
renderSeries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current filters and sorting to series data
|
||||
*/
|
||||
function applyFiltersAndSort() {
|
||||
let filtered = seriesData.slice();
|
||||
|
||||
// Sort based on the current sorting mode
|
||||
filtered.sort(function(a, b) {
|
||||
if (sortAlphabetical) {
|
||||
// Pure alphabetical sorting
|
||||
return AniWorld.UI.getDisplayName(a).localeCompare(AniWorld.UI.getDisplayName(b));
|
||||
} else {
|
||||
// Default sorting: missing episodes first (descending), then by name
|
||||
if (a.missing_episodes > 0 && b.missing_episodes === 0) return -1;
|
||||
if (a.missing_episodes === 0 && b.missing_episodes > 0) return 1;
|
||||
|
||||
// If both have missing episodes, sort by count (descending)
|
||||
if (a.missing_episodes > 0 && b.missing_episodes > 0) {
|
||||
if (a.missing_episodes !== b.missing_episodes) {
|
||||
return b.missing_episodes - a.missing_episodes;
|
||||
}
|
||||
}
|
||||
|
||||
// For series with same missing episode status, maintain stable order
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply missing episodes filter
|
||||
if (showMissingOnly) {
|
||||
filtered = filtered.filter(function(serie) {
|
||||
return serie.missing_episodes > 0;
|
||||
});
|
||||
}
|
||||
|
||||
filteredSeriesData = filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render series cards in the grid
|
||||
*/
|
||||
function renderSeries() {
|
||||
const grid = document.getElementById('series-grid');
|
||||
const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData :
|
||||
(seriesData.length > 0 ? seriesData : []);
|
||||
|
||||
if (dataToRender.length === 0) {
|
||||
const message = showMissingOnly ?
|
||||
'No series with missing episodes found.' :
|
||||
'No series found. Try searching for anime or rescanning your directory.';
|
||||
|
||||
grid.innerHTML =
|
||||
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +
|
||||
'<i class="fas fa-tv" style="font-size: 48px; color: var(--color-text-tertiary); margin-bottom: 1rem;"></i>' +
|
||||
'<p style="color: var(--color-text-secondary);">' + message + '</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = dataToRender.map(function(serie) {
|
||||
return createSerieCard(serie);
|
||||
}).join('');
|
||||
|
||||
// Bind checkbox events
|
||||
grid.querySelectorAll('.series-checkbox').forEach(function(checkbox) {
|
||||
checkbox.addEventListener('change', function(e) {
|
||||
if (AniWorld.SelectionManager) {
|
||||
AniWorld.SelectionManager.toggleSerieSelection(e.target.dataset.key, e.target.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTML for a series card
|
||||
* @param {Object} serie - Series data object
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function createSerieCard(serie) {
|
||||
const isSelected = AniWorld.SelectionManager ? AniWorld.SelectionManager.isSelected(serie.key) : false;
|
||||
const hasMissingEpisodes = serie.missing_episodes > 0;
|
||||
const canBeSelected = hasMissingEpisodes;
|
||||
|
||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + '" ' +
|
||||
'data-key="' + serie.key + '" data-folder="' + serie.folder + '">' +
|
||||
'<div class="series-card-header">' +
|
||||
'<input type="checkbox" class="series-checkbox" data-key="' + serie.key + '"' +
|
||||
(isSelected ? ' checked' : '') + (canBeSelected ? '' : ' disabled') + '>' +
|
||||
'<div class="series-info">' +
|
||||
'<h3>' + AniWorld.UI.escapeHtml(AniWorld.UI.getDisplayName(serie)) + '</h3>' +
|
||||
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="series-status">' +
|
||||
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="series-stats">' +
|
||||
'<div class="missing-episodes ' + (hasMissingEpisodes ? 'has-missing' : 'complete') + '">' +
|
||||
'<i class="fas ' + (hasMissingEpisodes ? 'fa-exclamation-triangle' : 'fa-check') + '"></i>' +
|
||||
'<span>' + (hasMissingEpisodes ? serie.missing_episodes + ' missing episodes' : 'Complete') + '</span>' +
|
||||
'</div>' +
|
||||
'<span class="series-site">' + serie.site + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all series data
|
||||
* @returns {Array} Series data array
|
||||
*/
|
||||
function getSeriesData() {
|
||||
return seriesData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered series data
|
||||
* @returns {Array} Filtered series data array
|
||||
*/
|
||||
function getFilteredSeriesData() {
|
||||
return filteredSeriesData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a series by key
|
||||
* @param {string} key - Series key
|
||||
* @returns {Object|undefined} Series object or undefined
|
||||
*/
|
||||
function findByKey(key) {
|
||||
return seriesData.find(function(s) {
|
||||
return s.key === key;
|
||||
});
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
loadSeries: loadSeries,
|
||||
renderSeries: renderSeries,
|
||||
applyFiltersAndSort: applyFiltersAndSort,
|
||||
getSeriesData: getSeriesData,
|
||||
getFilteredSeriesData: getFilteredSeriesData,
|
||||
findByKey: findByKey
|
||||
};
|
||||
})();
|
||||
421
src/server/web/static/js/index/socket-handler.js
Normal file
421
src/server/web/static/js/index/socket-handler.js
Normal file
@ -0,0 +1,421 @@
|
||||
/**
|
||||
* AniWorld - Socket Handler Module for Index Page
|
||||
*
|
||||
* Handles WebSocket events specific to the index page.
|
||||
*
|
||||
* Dependencies: constants.js, websocket-client.js, ui-utils.js, scan-manager.js, series-manager.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.IndexSocketHandler = (function() {
|
||||
'use strict';
|
||||
|
||||
const WS_EVENTS = AniWorld.Constants.WS_EVENTS;
|
||||
|
||||
// State
|
||||
let isDownloading = false;
|
||||
let isPaused = false;
|
||||
let localization = null;
|
||||
|
||||
/**
|
||||
* Initialize socket handler
|
||||
* @param {Object} localizationObj - Localization object
|
||||
*/
|
||||
function init(localizationObj) {
|
||||
localization = localizationObj;
|
||||
setupSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized text
|
||||
*/
|
||||
function getText(key) {
|
||||
if (localization && localization.getText) {
|
||||
return localization.getText(key);
|
||||
}
|
||||
// Fallback text
|
||||
const fallbacks = {
|
||||
'connected-server': 'Connected to server',
|
||||
'disconnected-server': 'Disconnected from server',
|
||||
'download-completed': 'Download completed',
|
||||
'download-failed': 'Download failed',
|
||||
'paused': 'Paused',
|
||||
'downloading': 'Downloading...',
|
||||
'connected': 'Connected',
|
||||
'disconnected': 'Disconnected'
|
||||
};
|
||||
return fallbacks[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up WebSocket event handlers
|
||||
*/
|
||||
function setupSocketHandlers() {
|
||||
const socket = AniWorld.WebSocketClient.getSocket();
|
||||
if (!socket) {
|
||||
console.warn('Socket not available for handler setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', function() {
|
||||
AniWorld.UI.showToast(getText('connected-server'), 'success');
|
||||
updateConnectionStatus(true);
|
||||
AniWorld.ScanManager.checkActiveScanStatus();
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
AniWorld.UI.showToast(getText('disconnected-server'), 'warning');
|
||||
updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
// Scan events
|
||||
socket.on(WS_EVENTS.SCAN_STARTED, function(data) {
|
||||
console.log('Scan started:', data);
|
||||
AniWorld.ScanManager.showScanProgressOverlay(data);
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', true);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.SCAN_PROGRESS, function(data) {
|
||||
console.log('Scan progress:', data);
|
||||
AniWorld.ScanManager.updateScanProgressOverlay(data);
|
||||
});
|
||||
|
||||
// Handle both legacy and new scan complete events
|
||||
const handleScanComplete = function(data) {
|
||||
console.log('Scan completed:', data);
|
||||
AniWorld.ScanManager.hideScanProgressOverlay(data);
|
||||
AniWorld.UI.showToast('Scan completed successfully', 'success');
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', false);
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
};
|
||||
socket.on(WS_EVENTS.SCAN_COMPLETED, handleScanComplete);
|
||||
socket.on(WS_EVENTS.SCAN_COMPLETE, handleScanComplete);
|
||||
|
||||
// Handle scan errors
|
||||
const handleScanError = function(data) {
|
||||
AniWorld.ConfigManager.hideStatus();
|
||||
AniWorld.UI.showToast('Scan error: ' + (data.message || data.error), 'error');
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', false, true);
|
||||
};
|
||||
socket.on(WS_EVENTS.SCAN_ERROR, handleScanError);
|
||||
socket.on(WS_EVENTS.SCAN_FAILED, handleScanError);
|
||||
|
||||
// Scheduled scan events
|
||||
socket.on(WS_EVENTS.SCHEDULED_RESCAN_STARTED, function() {
|
||||
AniWorld.UI.showToast('Scheduled rescan started', 'info');
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', true);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.SCHEDULED_RESCAN_COMPLETED, function(data) {
|
||||
AniWorld.UI.showToast('Scheduled rescan completed successfully', 'success');
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', false);
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.SCHEDULED_RESCAN_ERROR, function(data) {
|
||||
AniWorld.UI.showToast('Scheduled rescan error: ' + data.error, 'error');
|
||||
AniWorld.ScanManager.updateProcessStatus('rescan', false, true);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.SCHEDULED_RESCAN_SKIPPED, function(data) {
|
||||
AniWorld.UI.showToast('Scheduled rescan skipped: ' + data.reason, 'warning');
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.AUTO_DOWNLOAD_STARTED, function(data) {
|
||||
AniWorld.UI.showToast('Auto-download started after scheduled rescan', 'info');
|
||||
AniWorld.ScanManager.updateProcessStatus('download', true);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.AUTO_DOWNLOAD_ERROR, function(data) {
|
||||
AniWorld.UI.showToast('Auto-download error: ' + data.error, 'error');
|
||||
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
||||
});
|
||||
|
||||
// Download events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
isPaused = false;
|
||||
AniWorld.ScanManager.updateProcessStatus('download', true);
|
||||
showDownloadQueue(data);
|
||||
showStatus('Starting download of ' + data.total_series + ' series...', true, true);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_PROGRESS, function(data) {
|
||||
let status = '';
|
||||
let percent = 0;
|
||||
|
||||
if (data.progress !== undefined) {
|
||||
percent = data.progress;
|
||||
status = 'Downloading: ' + percent.toFixed(1) + '%';
|
||||
|
||||
if (data.speed_mbps && data.speed_mbps > 0) {
|
||||
status += ' (' + data.speed_mbps.toFixed(1) + ' Mbps)';
|
||||
}
|
||||
|
||||
if (data.eta_seconds && data.eta_seconds > 0) {
|
||||
const eta = AniWorld.UI.formatETA(data.eta_seconds);
|
||||
status += ' - ETA: ' + eta;
|
||||
}
|
||||
} else if (data.total_bytes) {
|
||||
percent = ((data.downloaded_bytes || 0) / data.total_bytes * 100);
|
||||
status = 'Downloading: ' + percent.toFixed(1) + '%';
|
||||
} else if (data.downloaded_mb !== undefined) {
|
||||
status = 'Downloaded: ' + data.downloaded_mb.toFixed(1) + ' MB';
|
||||
} else {
|
||||
status = 'Downloading: ' + (data.percent || '0%');
|
||||
}
|
||||
|
||||
if (percent > 0) {
|
||||
updateProgress(percent, status);
|
||||
} else {
|
||||
updateStatus(status);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_COMPLETED, function(data) {
|
||||
isDownloading = false;
|
||||
isPaused = false;
|
||||
hideDownloadQueue();
|
||||
AniWorld.ConfigManager.hideStatus();
|
||||
AniWorld.UI.showToast(getText('download-completed'), 'success');
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
AniWorld.SelectionManager.clearSelection();
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_ERROR, function(data) {
|
||||
isDownloading = false;
|
||||
isPaused = false;
|
||||
hideDownloadQueue();
|
||||
AniWorld.ConfigManager.hideStatus();
|
||||
AniWorld.UI.showToast(getText('download-failed') + ': ' + data.message, 'error');
|
||||
});
|
||||
|
||||
// Download queue events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_QUEUE_COMPLETED, function() {
|
||||
AniWorld.ScanManager.updateProcessStatus('download', false);
|
||||
AniWorld.UI.showToast('All downloads completed!', 'success');
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STOP_REQUESTED, function() {
|
||||
AniWorld.UI.showToast('Stopping downloads...', 'info');
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STOPPED, function() {
|
||||
AniWorld.ScanManager.updateProcessStatus('download', false);
|
||||
AniWorld.UI.showToast('Downloads stopped', 'success');
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_QUEUE_UPDATE, function(data) {
|
||||
updateDownloadQueue(data);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_EPISODE_UPDATE, function(data) {
|
||||
updateCurrentEpisode(data);
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_SERIES_COMPLETED, function(data) {
|
||||
updateDownloadProgress(data);
|
||||
});
|
||||
|
||||
// Download control events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_PAUSED, function() {
|
||||
isPaused = true;
|
||||
updateStatus(getText('paused'));
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_RESUMED, function() {
|
||||
isPaused = false;
|
||||
updateStatus(getText('downloading'));
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.DOWNLOAD_CANCELLED, function() {
|
||||
isDownloading = false;
|
||||
isPaused = false;
|
||||
hideDownloadQueue();
|
||||
AniWorld.ConfigManager.hideStatus();
|
||||
AniWorld.UI.showToast('Download cancelled', 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status display
|
||||
*/
|
||||
function updateConnectionStatus(connected) {
|
||||
const indicator = document.getElementById('connection-status-display');
|
||||
if (indicator) {
|
||||
const statusIndicator = indicator.querySelector('.status-indicator');
|
||||
const statusText = indicator.querySelector('.status-text');
|
||||
|
||||
if (connected) {
|
||||
statusIndicator.classList.add('connected');
|
||||
statusText.textContent = getText('connected');
|
||||
} else {
|
||||
statusIndicator.classList.remove('connected');
|
||||
statusText.textContent = getText('disconnected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status panel
|
||||
*/
|
||||
function showStatus(message, showProgress, showControls) {
|
||||
showProgress = showProgress || false;
|
||||
showControls = showControls || false;
|
||||
|
||||
const panel = document.getElementById('status-panel');
|
||||
const messageEl = document.getElementById('status-message');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const controlsContainer = document.getElementById('download-controls');
|
||||
|
||||
messageEl.textContent = message;
|
||||
progressContainer.classList.toggle('hidden', !showProgress);
|
||||
controlsContainer.classList.toggle('hidden', !showControls);
|
||||
|
||||
if (showProgress) {
|
||||
updateProgress(0);
|
||||
}
|
||||
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status message
|
||||
*/
|
||||
function updateStatus(message) {
|
||||
document.getElementById('status-message').textContent = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress bar
|
||||
*/
|
||||
function updateProgress(percent, message) {
|
||||
const fill = document.getElementById('progress-fill');
|
||||
const text = document.getElementById('progress-text');
|
||||
|
||||
fill.style.width = percent + '%';
|
||||
text.textContent = message || percent + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show download queue
|
||||
*/
|
||||
function showDownloadQueue(data) {
|
||||
const queueSection = document.getElementById('download-queue-section');
|
||||
const queueProgress = document.getElementById('queue-progress');
|
||||
|
||||
queueProgress.textContent = '0/' + data.total_series + ' series';
|
||||
updateDownloadQueue({
|
||||
queue: data.queue || [],
|
||||
current_downloading: null,
|
||||
stats: {
|
||||
completed_series: 0,
|
||||
total_series: data.total_series
|
||||
}
|
||||
});
|
||||
|
||||
queueSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide download queue
|
||||
*/
|
||||
function hideDownloadQueue() {
|
||||
const queueSection = document.getElementById('download-queue-section');
|
||||
const currentDownload = document.getElementById('current-download');
|
||||
|
||||
queueSection.classList.add('hidden');
|
||||
currentDownload.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update download queue display
|
||||
*/
|
||||
function updateDownloadQueue(data) {
|
||||
const queueList = document.getElementById('queue-list');
|
||||
const currentDownload = document.getElementById('current-download');
|
||||
const queueProgress = document.getElementById('queue-progress');
|
||||
|
||||
// Update overall progress
|
||||
if (data.stats) {
|
||||
queueProgress.textContent = data.stats.completed_series + '/' + data.stats.total_series + ' series';
|
||||
}
|
||||
|
||||
// Update current downloading
|
||||
if (data.current_downloading) {
|
||||
currentDownload.classList.remove('hidden');
|
||||
document.getElementById('current-serie-name').textContent = AniWorld.UI.getDisplayName(data.current_downloading);
|
||||
document.getElementById('current-episode').textContent = data.current_downloading.missing_episodes + ' episodes remaining';
|
||||
} else {
|
||||
currentDownload.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update queue list
|
||||
if (data.queue && data.queue.length > 0) {
|
||||
queueList.innerHTML = data.queue.map(function(serie, index) {
|
||||
return '<div class="queue-item">' +
|
||||
'<div class="queue-item-index">' + (index + 1) + '</div>' +
|
||||
'<div class="queue-item-name">' + AniWorld.UI.escapeHtml(AniWorld.UI.getDisplayName(serie)) + '</div>' +
|
||||
'<div class="queue-item-status">Waiting</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} else {
|
||||
queueList.innerHTML = '<div class="queue-empty">No series in queue</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current episode display
|
||||
*/
|
||||
function updateCurrentEpisode(data) {
|
||||
const currentEpisode = document.getElementById('current-episode');
|
||||
const progressFill = document.getElementById('current-progress-fill');
|
||||
const progressText = document.getElementById('current-progress-text');
|
||||
|
||||
if (currentEpisode && data.episode) {
|
||||
currentEpisode.textContent = data.episode + ' (' + data.episode_progress + ')';
|
||||
}
|
||||
|
||||
if (data.overall_progress && progressFill && progressText) {
|
||||
const parts = data.overall_progress.split('/');
|
||||
const current = parseInt(parts[0]);
|
||||
const total = parseInt(parts[1]);
|
||||
const percent = total > 0 ? (current / total * 100).toFixed(1) : 0;
|
||||
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent = percent + '%';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update download progress display
|
||||
*/
|
||||
function updateDownloadProgress(data) {
|
||||
const queueProgress = document.getElementById('queue-progress');
|
||||
|
||||
if (queueProgress && data.completed_series && data.total_series) {
|
||||
queueProgress.textContent = data.completed_series + '/' + data.total_series + ' series';
|
||||
}
|
||||
|
||||
AniWorld.UI.showToast('Completed: ' + data.serie, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download state
|
||||
*/
|
||||
function getDownloadState() {
|
||||
return {
|
||||
isDownloading: isDownloading,
|
||||
isPaused: isPaused
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
updateConnectionStatus: updateConnectionStatus,
|
||||
getDownloadState: getDownloadState
|
||||
};
|
||||
})();
|
||||
189
src/server/web/static/js/queue/progress-handler.js
Normal file
189
src/server/web/static/js/queue/progress-handler.js
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* AniWorld - Progress Handler Module
|
||||
*
|
||||
* Handles real-time download progress updates.
|
||||
*
|
||||
* Dependencies: constants.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ProgressHandler = (function() {
|
||||
'use strict';
|
||||
|
||||
// Store progress updates waiting for cards
|
||||
let pendingProgressUpdates = new Map();
|
||||
|
||||
/**
|
||||
* Update download progress in real-time
|
||||
* @param {Object} data - Progress data from WebSocket
|
||||
*/
|
||||
function updateDownloadProgress(data) {
|
||||
console.log('updateDownloadProgress called with:', JSON.stringify(data, null, 2));
|
||||
|
||||
// Extract download ID - prioritize metadata.item_id (actual item ID)
|
||||
let downloadId = null;
|
||||
|
||||
// First try metadata.item_id (this is the actual download item ID)
|
||||
if (data.metadata && data.metadata.item_id) {
|
||||
downloadId = data.metadata.item_id;
|
||||
}
|
||||
|
||||
// Fallback to other ID fields
|
||||
if (!downloadId) {
|
||||
downloadId = data.item_id || data.download_id;
|
||||
}
|
||||
|
||||
// If ID starts with "download_", extract the actual ID
|
||||
if (!downloadId && data.id) {
|
||||
if (data.id.startsWith('download_')) {
|
||||
downloadId = data.id.substring(9);
|
||||
} else {
|
||||
downloadId = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if data is wrapped in another 'data' property
|
||||
if (!downloadId && data.data) {
|
||||
if (data.data.metadata && data.data.metadata.item_id) {
|
||||
downloadId = data.data.metadata.item_id;
|
||||
} else if (data.data.item_id) {
|
||||
downloadId = data.data.item_id;
|
||||
} else if (data.data.id && data.data.id.startsWith('download_')) {
|
||||
downloadId = data.data.id.substring(9);
|
||||
} else {
|
||||
downloadId = data.data.id || data.data.download_id;
|
||||
}
|
||||
data = data.data;
|
||||
}
|
||||
|
||||
if (!downloadId) {
|
||||
console.warn('No download ID in progress data');
|
||||
console.warn('Data structure:', data);
|
||||
console.warn('Available keys:', Object.keys(data));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Looking for download card with ID: ' + downloadId);
|
||||
|
||||
// Find the download card in active downloads
|
||||
const card = document.querySelector('[data-download-id="' + downloadId + '"]');
|
||||
if (!card) {
|
||||
console.warn('Download card not found for ID: ' + downloadId);
|
||||
|
||||
// Debug: Log all existing download cards
|
||||
const allCards = document.querySelectorAll('[data-download-id]');
|
||||
console.log('Found ' + allCards.length + ' download cards:');
|
||||
allCards.forEach(function(c) {
|
||||
console.log(' - ' + c.getAttribute('data-download-id'));
|
||||
});
|
||||
|
||||
// Store this progress update to retry after queue loads
|
||||
console.log('Storing progress update for ' + downloadId + ' to retry after reload');
|
||||
pendingProgressUpdates.set(downloadId, data);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Found download card for ID: ' + downloadId + ', updating progress');
|
||||
|
||||
// Extract progress information
|
||||
const progress = data.progress || data;
|
||||
const percent = progress.percent || 0;
|
||||
const metadata = progress.metadata || data.metadata || {};
|
||||
|
||||
// Check data format
|
||||
let speed;
|
||||
|
||||
if (progress.downloaded_mb !== undefined && progress.total_mb !== undefined) {
|
||||
// yt-dlp detailed format
|
||||
speed = progress.speed_mbps ? progress.speed_mbps.toFixed(1) : '0.0';
|
||||
} else if (progress.current !== undefined && progress.total !== undefined) {
|
||||
// ProgressService basic format
|
||||
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
|
||||
} else {
|
||||
// Fallback
|
||||
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
const progressFill = card.querySelector('.progress-fill');
|
||||
if (progressFill) {
|
||||
progressFill.style.width = percent + '%';
|
||||
}
|
||||
|
||||
// Update progress text
|
||||
const progressInfo = card.querySelector('.progress-info');
|
||||
if (progressInfo) {
|
||||
const percentSpan = progressInfo.querySelector('span:first-child');
|
||||
const speedSpan = progressInfo.querySelector('.download-speed');
|
||||
|
||||
if (percentSpan) {
|
||||
percentSpan.textContent = percent > 0 ? percent.toFixed(1) + '%' : 'Starting...';
|
||||
}
|
||||
if (speedSpan) {
|
||||
speedSpan.textContent = speed + ' MB/s';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Updated progress for ' + downloadId + ': ' + percent.toFixed(1) + '%');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending progress updates
|
||||
*/
|
||||
function processPendingProgressUpdates() {
|
||||
if (pendingProgressUpdates.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Processing ' + pendingProgressUpdates.size + ' pending progress updates...');
|
||||
|
||||
// Process each pending update
|
||||
const processed = [];
|
||||
pendingProgressUpdates.forEach(function(data, downloadId) {
|
||||
// Check if card now exists
|
||||
const card = document.querySelector('[data-download-id="' + downloadId + '"]');
|
||||
if (card) {
|
||||
console.log('Retrying progress update for ' + downloadId);
|
||||
updateDownloadProgress(data);
|
||||
processed.push(downloadId);
|
||||
} else {
|
||||
console.log('Card still not found for ' + downloadId + ', will retry on next reload');
|
||||
}
|
||||
});
|
||||
|
||||
// Remove processed updates
|
||||
processed.forEach(function(id) {
|
||||
pendingProgressUpdates.delete(id);
|
||||
});
|
||||
|
||||
if (processed.length > 0) {
|
||||
console.log('Successfully processed ' + processed.length + ' pending updates');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending progress updates
|
||||
*/
|
||||
function clearPendingUpdates() {
|
||||
pendingProgressUpdates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending update for specific download
|
||||
* @param {string} downloadId - Download ID
|
||||
*/
|
||||
function clearPendingUpdate(downloadId) {
|
||||
pendingProgressUpdates.delete(downloadId);
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
updateDownloadProgress: updateDownloadProgress,
|
||||
processPendingProgressUpdates: processPendingProgressUpdates,
|
||||
clearPendingUpdates: clearPendingUpdates,
|
||||
clearPendingUpdate: clearPendingUpdate
|
||||
};
|
||||
})();
|
||||
159
src/server/web/static/js/queue/queue-api.js
Normal file
159
src/server/web/static/js/queue/queue-api.js
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* AniWorld - Queue API Module
|
||||
*
|
||||
* Handles API requests for the download queue.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.QueueAPI = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Load queue data from server
|
||||
* @returns {Promise<Object>} Queue data
|
||||
*/
|
||||
async function loadQueueData() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(API.QUEUE_STATUS);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// API returns nested structure with 'status' and 'statistics'
|
||||
// Transform it to the expected flat structure
|
||||
return {
|
||||
...data.status,
|
||||
statistics: data.statistics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading queue data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start queue processing
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function startQueue() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.QUEUE_START, {});
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error starting queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queue processing
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function stopQueue() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.QUEUE_STOP, {});
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error stopping queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from queue
|
||||
* @param {string} downloadId - Download item ID
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function removeFromQueue(downloadId) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.delete(API.QUEUE_REMOVE + '/' + downloadId);
|
||||
if (!response) return false;
|
||||
return response.status === 204;
|
||||
} catch (error) {
|
||||
console.error('Error removing from queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed downloads
|
||||
* @param {Array<string>} itemIds - Array of download item IDs
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function retryDownloads(itemIds) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(API.QUEUE_RETRY, { item_ids: itemIds });
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error retrying downloads:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear completed downloads
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function clearCompleted() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.delete(API.QUEUE_COMPLETED);
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error clearing completed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear failed downloads
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function clearFailed() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.delete(API.QUEUE_FAILED);
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error clearing failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending downloads
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function clearPending() {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.delete(API.QUEUE_PENDING);
|
||||
if (!response) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error clearing pending:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
loadQueueData: loadQueueData,
|
||||
startQueue: startQueue,
|
||||
stopQueue: stopQueue,
|
||||
removeFromQueue: removeFromQueue,
|
||||
retryDownloads: retryDownloads,
|
||||
clearCompleted: clearCompleted,
|
||||
clearFailed: clearFailed,
|
||||
clearPending: clearPending
|
||||
};
|
||||
})();
|
||||
313
src/server/web/static/js/queue/queue-init.js
Normal file
313
src/server/web/static/js/queue/queue-init.js
Normal file
@ -0,0 +1,313 @@
|
||||
/**
|
||||
* AniWorld - Queue Page Application Initializer
|
||||
*
|
||||
* Main entry point for the queue page. Initializes all modules.
|
||||
*
|
||||
* Dependencies: All shared and queue modules
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.QueueApp = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Initialize the queue page application
|
||||
*/
|
||||
async function init() {
|
||||
console.log('AniWorld Queue App initializing...');
|
||||
|
||||
// Check authentication first
|
||||
const isAuthenticated = await AniWorld.Auth.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
return; // Auth module handles redirect
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
AniWorld.Theme.init();
|
||||
|
||||
// Initialize WebSocket connection
|
||||
AniWorld.WebSocketClient.init();
|
||||
|
||||
// Initialize socket event handlers for this page
|
||||
AniWorld.QueueSocketHandler.init(AniWorld.QueueApp);
|
||||
|
||||
// Bind UI events
|
||||
bindEvents();
|
||||
|
||||
// Load initial data
|
||||
await loadQueueData();
|
||||
|
||||
console.log('AniWorld Queue App initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind UI event handlers
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
AniWorld.Theme.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// Queue management actions
|
||||
const clearCompletedBtn = document.getElementById('clear-completed-btn');
|
||||
if (clearCompletedBtn) {
|
||||
clearCompletedBtn.addEventListener('click', function() {
|
||||
clearQueue('completed');
|
||||
});
|
||||
}
|
||||
|
||||
const clearFailedBtn = document.getElementById('clear-failed-btn');
|
||||
if (clearFailedBtn) {
|
||||
clearFailedBtn.addEventListener('click', function() {
|
||||
clearQueue('failed');
|
||||
});
|
||||
}
|
||||
|
||||
const clearPendingBtn = document.getElementById('clear-pending-btn');
|
||||
if (clearPendingBtn) {
|
||||
clearPendingBtn.addEventListener('click', function() {
|
||||
clearQueue('pending');
|
||||
});
|
||||
}
|
||||
|
||||
const retryAllBtn = document.getElementById('retry-all-btn');
|
||||
if (retryAllBtn) {
|
||||
retryAllBtn.addEventListener('click', retryAllFailed);
|
||||
}
|
||||
|
||||
// Download controls
|
||||
const startQueueBtn = document.getElementById('start-queue-btn');
|
||||
if (startQueueBtn) {
|
||||
startQueueBtn.addEventListener('click', startDownload);
|
||||
}
|
||||
|
||||
const stopQueueBtn = document.getElementById('stop-queue-btn');
|
||||
if (stopQueueBtn) {
|
||||
stopQueueBtn.addEventListener('click', stopDownloads);
|
||||
}
|
||||
|
||||
// Modal events
|
||||
const closeConfirm = document.getElementById('close-confirm');
|
||||
if (closeConfirm) {
|
||||
closeConfirm.addEventListener('click', AniWorld.UI.hideConfirmModal);
|
||||
}
|
||||
|
||||
const confirmCancel = document.getElementById('confirm-cancel');
|
||||
if (confirmCancel) {
|
||||
confirmCancel.addEventListener('click', AniWorld.UI.hideConfirmModal);
|
||||
}
|
||||
|
||||
const modalOverlay = document.querySelector('#confirm-modal .modal-overlay');
|
||||
if (modalOverlay) {
|
||||
modalOverlay.addEventListener('click', AniWorld.UI.hideConfirmModal);
|
||||
}
|
||||
|
||||
// Logout functionality
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
AniWorld.Auth.logout(AniWorld.UI.showToast);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue data and update display
|
||||
*/
|
||||
async function loadQueueData() {
|
||||
const data = await AniWorld.QueueAPI.loadQueueData();
|
||||
if (data) {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data);
|
||||
AniWorld.ProgressHandler.processPendingProgressUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear queue by type
|
||||
* @param {string} type - 'completed', 'failed', or 'pending'
|
||||
*/
|
||||
async function clearQueue(type) {
|
||||
const titles = {
|
||||
completed: 'Clear Completed Downloads',
|
||||
failed: 'Clear Failed Downloads',
|
||||
pending: 'Remove All Pending Downloads'
|
||||
};
|
||||
|
||||
const messages = {
|
||||
completed: 'Are you sure you want to clear all completed downloads?',
|
||||
failed: 'Are you sure you want to clear all failed downloads?',
|
||||
pending: 'Are you sure you want to remove all pending downloads from the queue?'
|
||||
};
|
||||
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(titles[type], messages[type]);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (type === 'completed') {
|
||||
data = await AniWorld.QueueAPI.clearCompleted();
|
||||
AniWorld.UI.showToast('Cleared ' + (data?.count || 0) + ' completed downloads', 'success');
|
||||
} else if (type === 'failed') {
|
||||
data = await AniWorld.QueueAPI.clearFailed();
|
||||
AniWorld.UI.showToast('Cleared ' + (data?.count || 0) + ' failed downloads', 'success');
|
||||
} else if (type === 'pending') {
|
||||
data = await AniWorld.QueueAPI.clearPending();
|
||||
AniWorld.UI.showToast('Removed ' + (data?.count || 0) + ' pending downloads', 'success');
|
||||
}
|
||||
await loadQueueData();
|
||||
} catch (error) {
|
||||
console.error('Error clearing queue:', error);
|
||||
AniWorld.UI.showToast('Failed to clear queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed download
|
||||
* @param {string} downloadId - Download item ID
|
||||
*/
|
||||
async function retryDownload(downloadId) {
|
||||
try {
|
||||
const data = await AniWorld.QueueAPI.retryDownloads([downloadId]);
|
||||
AniWorld.UI.showToast('Retried ' + (data?.retried_count || 1) + ' download(s)', 'success');
|
||||
await loadQueueData();
|
||||
} catch (error) {
|
||||
console.error('Error retrying download:', error);
|
||||
AniWorld.UI.showToast('Failed to retry download', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all failed downloads
|
||||
*/
|
||||
async function retryAllFailed() {
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||
'Retry All Failed Downloads',
|
||||
'Are you sure you want to retry all failed downloads?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// Get all failed download IDs
|
||||
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
|
||||
const itemIds = Array.from(failedCards).map(function(card) {
|
||||
return card.dataset.id;
|
||||
}).filter(function(id) {
|
||||
return id;
|
||||
});
|
||||
|
||||
if (itemIds.length === 0) {
|
||||
AniWorld.UI.showToast('No failed downloads to retry', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await AniWorld.QueueAPI.retryDownloads(itemIds);
|
||||
AniWorld.UI.showToast('Retried ' + (data?.retried_count || itemIds.length) + ' download(s)', 'success');
|
||||
await loadQueueData();
|
||||
} catch (error) {
|
||||
console.error('Error retrying failed downloads:', error);
|
||||
AniWorld.UI.showToast('Failed to retry downloads', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from queue
|
||||
* @param {string} downloadId - Download item ID
|
||||
*/
|
||||
async function removeFromQueue(downloadId) {
|
||||
try {
|
||||
const success = await AniWorld.QueueAPI.removeFromQueue(downloadId);
|
||||
if (success) {
|
||||
AniWorld.UI.showToast('Download removed from queue', 'success');
|
||||
await loadQueueData();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to remove from queue', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing from queue:', error);
|
||||
AniWorld.UI.showToast('Failed to remove from queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start queue processing
|
||||
*/
|
||||
async function startDownload() {
|
||||
try {
|
||||
const data = await AniWorld.QueueAPI.startQueue();
|
||||
|
||||
if (data && data.status === 'success') {
|
||||
AniWorld.UI.showToast('Queue processing started - all items will download automatically', 'success');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('start-queue-btn').style.display = 'none';
|
||||
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
|
||||
document.getElementById('stop-queue-btn').disabled = false;
|
||||
|
||||
await loadQueueData();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to start queue: ' + (data?.message || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting queue:', error);
|
||||
AniWorld.UI.showToast('Failed to start queue processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queue processing
|
||||
*/
|
||||
async function stopDownloads() {
|
||||
try {
|
||||
const data = await AniWorld.QueueAPI.stopQueue();
|
||||
|
||||
if (data && data.status === 'success') {
|
||||
AniWorld.UI.showToast('Queue processing stopped', 'success');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('stop-queue-btn').style.display = 'none';
|
||||
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
||||
document.getElementById('start-queue-btn').disabled = false;
|
||||
|
||||
await loadQueueData();
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to stop queue: ' + (data?.message || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping queue:', error);
|
||||
AniWorld.UI.showToast('Failed to stop queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
loadQueueData: loadQueueData,
|
||||
retryDownload: retryDownload,
|
||||
removeFromQueue: removeFromQueue,
|
||||
startDownload: startDownload,
|
||||
stopDownloads: stopDownloads
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
AniWorld.QueueApp.init();
|
||||
});
|
||||
|
||||
// Expose for inline event handlers (backwards compatibility)
|
||||
window.queueManager = {
|
||||
retryDownload: function(id) {
|
||||
return AniWorld.QueueApp.retryDownload(id);
|
||||
},
|
||||
removeFromQueue: function(id) {
|
||||
return AniWorld.QueueApp.removeFromQueue(id);
|
||||
},
|
||||
removeFailedDownload: function(id) {
|
||||
return AniWorld.QueueApp.removeFromQueue(id);
|
||||
}
|
||||
};
|
||||
335
src/server/web/static/js/queue/queue-renderer.js
Normal file
335
src/server/web/static/js/queue/queue-renderer.js
Normal file
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* AniWorld - Queue Renderer Module
|
||||
*
|
||||
* Handles rendering of queue items and statistics.
|
||||
*
|
||||
* Dependencies: constants.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.QueueRenderer = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Update full queue display
|
||||
* @param {Object} data - Queue data
|
||||
*/
|
||||
function updateQueueDisplay(data) {
|
||||
// Update statistics
|
||||
updateStatistics(data.statistics, data);
|
||||
|
||||
// Update active downloads
|
||||
renderActiveDownloads(data.active_downloads || []);
|
||||
|
||||
// Update pending queue
|
||||
renderPendingQueue(data.pending_queue || []);
|
||||
|
||||
// Update completed downloads
|
||||
renderCompletedDownloads(data.completed_downloads || []);
|
||||
|
||||
// Update failed downloads
|
||||
renderFailedDownloads(data.failed_downloads || []);
|
||||
|
||||
// Update button states
|
||||
updateButtonStates(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update statistics display
|
||||
* @param {Object} stats - Statistics object
|
||||
* @param {Object} data - Full queue data
|
||||
*/
|
||||
function updateStatistics(stats, data) {
|
||||
const statistics = stats || {};
|
||||
|
||||
document.getElementById('total-items').textContent = statistics.total_items || 0;
|
||||
document.getElementById('pending-items').textContent = (data.pending_queue || []).length;
|
||||
document.getElementById('completed-items').textContent = statistics.completed_items || 0;
|
||||
document.getElementById('failed-items').textContent = statistics.failed_items || 0;
|
||||
|
||||
// Update section counts
|
||||
document.getElementById('queue-count').textContent = (data.pending_queue || []).length;
|
||||
document.getElementById('completed-count').textContent = statistics.completed_items || 0;
|
||||
document.getElementById('failed-count').textContent = statistics.failed_items || 0;
|
||||
|
||||
document.getElementById('current-speed').textContent = statistics.current_speed || '0 MB/s';
|
||||
document.getElementById('average-speed').textContent = statistics.average_speed || '0 MB/s';
|
||||
|
||||
// Format ETA
|
||||
const etaElement = document.getElementById('eta-time');
|
||||
if (statistics.eta) {
|
||||
const eta = new Date(statistics.eta);
|
||||
const now = new Date();
|
||||
const diffMs = eta - now;
|
||||
|
||||
if (diffMs > 0) {
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
etaElement.textContent = hours + 'h ' + minutes + 'm';
|
||||
} else {
|
||||
etaElement.textContent = 'Calculating...';
|
||||
}
|
||||
} else {
|
||||
etaElement.textContent = '--:--';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render active downloads
|
||||
* @param {Array} downloads - Active downloads array
|
||||
*/
|
||||
function renderActiveDownloads(downloads) {
|
||||
const container = document.getElementById('active-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<i class="fas fa-pause-circle"></i>' +
|
||||
'<p>No active downloads</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(function(download) {
|
||||
return createActiveDownloadCard(download);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create active download card HTML
|
||||
* @param {Object} download - Download item
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function createActiveDownloadCard(download) {
|
||||
const progress = download.progress || {};
|
||||
const progressPercent = progress.percent || 0;
|
||||
const speed = progress.speed_mbps ? progress.speed_mbps.toFixed(1) + ' MB/s' : '0 MB/s';
|
||||
|
||||
const episodeNum = String(download.episode.episode).padStart(2, '0');
|
||||
const episodeTitle = download.episode.title || 'Episode ' + download.episode.episode;
|
||||
|
||||
return '<div class="download-card active" data-download-id="' + download.id + '">' +
|
||||
'<div class="download-header">' +
|
||||
'<div class="download-info">' +
|
||||
'<h4>' + AniWorld.UI.escapeHtml(download.serie_name) + '</h4>' +
|
||||
'<p>' + AniWorld.UI.escapeHtml(download.episode.season) + 'x' + episodeNum + ' - ' +
|
||||
AniWorld.UI.escapeHtml(episodeTitle) + '</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="download-progress">' +
|
||||
'<div class="progress-bar">' +
|
||||
'<div class="progress-fill" style="width: ' + progressPercent + '%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progress-info">' +
|
||||
'<span>' + (progressPercent > 0 ? progressPercent.toFixed(1) + '%' : 'Starting...') + '</span>' +
|
||||
'<span class="download-speed">' + speed + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render pending queue
|
||||
* @param {Array} queue - Pending queue array
|
||||
*/
|
||||
function renderPendingQueue(queue) {
|
||||
const container = document.getElementById('pending-queue');
|
||||
|
||||
if (queue.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<i class="fas fa-list"></i>' +
|
||||
'<p>No items in queue</p>' +
|
||||
'<small>Add episodes from the main page to start downloading</small>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = queue.map(function(item, index) {
|
||||
return createPendingQueueCard(item, index);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pending queue card HTML
|
||||
* @param {Object} download - Download item
|
||||
* @param {number} index - Queue position
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function createPendingQueueCard(download, index) {
|
||||
const addedAt = new Date(download.added_at).toLocaleString();
|
||||
const episodeNum = String(download.episode.episode).padStart(2, '0');
|
||||
const episodeTitle = download.episode.title || 'Episode ' + download.episode.episode;
|
||||
|
||||
return '<div class="download-card pending" data-id="' + download.id + '" data-index="' + index + '">' +
|
||||
'<div class="queue-position">' + (index + 1) + '</div>' +
|
||||
'<div class="download-header">' +
|
||||
'<div class="download-info">' +
|
||||
'<h4>' + AniWorld.UI.escapeHtml(download.serie_name) + '</h4>' +
|
||||
'<p>' + AniWorld.UI.escapeHtml(download.episode.season) + 'x' + episodeNum + ' - ' +
|
||||
AniWorld.UI.escapeHtml(episodeTitle) + '</p>' +
|
||||
'<small>Added: ' + addedAt + '</small>' +
|
||||
'</div>' +
|
||||
'<div class="download-actions">' +
|
||||
'<button class="btn btn-small btn-secondary" onclick="AniWorld.QueueApp.removeFromQueue(\'' + download.id + '\')">' +
|
||||
'<i class="fas fa-trash"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render completed downloads
|
||||
* @param {Array} downloads - Completed downloads array
|
||||
*/
|
||||
function renderCompletedDownloads(downloads) {
|
||||
const container = document.getElementById('completed-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<i class="fas fa-check-circle"></i>' +
|
||||
'<p>No completed downloads</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(function(download) {
|
||||
return createCompletedDownloadCard(download);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completed download card HTML
|
||||
* @param {Object} download - Download item
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function createCompletedDownloadCard(download) {
|
||||
const completedAt = new Date(download.completed_at).toLocaleString();
|
||||
const duration = AniWorld.UI.calculateDuration(download.started_at, download.completed_at);
|
||||
const episodeNum = String(download.episode.episode).padStart(2, '0');
|
||||
const episodeTitle = download.episode.title || 'Episode ' + download.episode.episode;
|
||||
|
||||
return '<div class="download-card completed">' +
|
||||
'<div class="download-header">' +
|
||||
'<div class="download-info">' +
|
||||
'<h4>' + AniWorld.UI.escapeHtml(download.serie_name) + '</h4>' +
|
||||
'<p>' + AniWorld.UI.escapeHtml(download.episode.season) + 'x' + episodeNum + ' - ' +
|
||||
AniWorld.UI.escapeHtml(episodeTitle) + '</p>' +
|
||||
'<small>Completed: ' + completedAt + ' (' + duration + ')</small>' +
|
||||
'</div>' +
|
||||
'<div class="download-status">' +
|
||||
'<i class="fas fa-check-circle text-success"></i>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render failed downloads
|
||||
* @param {Array} downloads - Failed downloads array
|
||||
*/
|
||||
function renderFailedDownloads(downloads) {
|
||||
const container = document.getElementById('failed-downloads');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="empty-state">' +
|
||||
'<i class="fas fa-check-circle text-success"></i>' +
|
||||
'<p>No failed downloads</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = downloads.map(function(download) {
|
||||
return createFailedDownloadCard(download);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failed download card HTML
|
||||
* @param {Object} download - Download item
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
function createFailedDownloadCard(download) {
|
||||
const failedAt = new Date(download.completed_at).toLocaleString();
|
||||
const retryCount = download.retry_count || 0;
|
||||
const episodeNum = String(download.episode.episode).padStart(2, '0');
|
||||
const episodeTitle = download.episode.title || 'Episode ' + download.episode.episode;
|
||||
|
||||
return '<div class="download-card failed" data-id="' + download.id + '">' +
|
||||
'<div class="download-header">' +
|
||||
'<div class="download-info">' +
|
||||
'<h4>' + AniWorld.UI.escapeHtml(download.serie_name) + '</h4>' +
|
||||
'<p>' + AniWorld.UI.escapeHtml(download.episode.season) + 'x' + episodeNum + ' - ' +
|
||||
AniWorld.UI.escapeHtml(episodeTitle) + '</p>' +
|
||||
'<small>Failed: ' + failedAt + (retryCount > 0 ? ' (Retry ' + retryCount + ')' : '') + '</small>' +
|
||||
(download.error ? '<small class="error-message">' + AniWorld.UI.escapeHtml(download.error) + '</small>' : '') +
|
||||
'</div>' +
|
||||
'<div class="download-actions">' +
|
||||
'<button class="btn btn-small btn-warning" onclick="AniWorld.QueueApp.retryDownload(\'' + download.id + '\')">' +
|
||||
'<i class="fas fa-redo"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn btn-small btn-secondary" onclick="AniWorld.QueueApp.removeFromQueue(\'' + download.id + '\')">' +
|
||||
'<i class="fas fa-trash"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button states based on queue data
|
||||
* @param {Object} data - Queue data
|
||||
*/
|
||||
function updateButtonStates(data) {
|
||||
const hasActive = (data.active_downloads || []).length > 0;
|
||||
const hasPending = (data.pending_queue || []).length > 0;
|
||||
const hasFailed = (data.failed_downloads || []).length > 0;
|
||||
const hasCompleted = (data.completed_downloads || []).length > 0;
|
||||
|
||||
console.log('Button states update:', {
|
||||
hasPending: hasPending,
|
||||
pendingCount: (data.pending_queue || []).length,
|
||||
hasActive: hasActive,
|
||||
hasFailed: hasFailed,
|
||||
hasCompleted: hasCompleted
|
||||
});
|
||||
|
||||
// Enable start button only if there are pending items and no active downloads
|
||||
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
|
||||
|
||||
// Show/hide start/stop buttons based on whether downloads are active
|
||||
if (hasActive) {
|
||||
document.getElementById('start-queue-btn').style.display = 'none';
|
||||
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
|
||||
document.getElementById('stop-queue-btn').disabled = false;
|
||||
} else {
|
||||
document.getElementById('stop-queue-btn').style.display = 'none';
|
||||
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
document.getElementById('retry-all-btn').disabled = !hasFailed;
|
||||
document.getElementById('clear-completed-btn').disabled = !hasCompleted;
|
||||
document.getElementById('clear-failed-btn').disabled = !hasFailed;
|
||||
|
||||
// Update clear pending button if it exists
|
||||
const clearPendingBtn = document.getElementById('clear-pending-btn');
|
||||
if (clearPendingBtn) {
|
||||
clearPendingBtn.disabled = !hasPending;
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
updateQueueDisplay: updateQueueDisplay,
|
||||
updateStatistics: updateStatistics,
|
||||
renderActiveDownloads: renderActiveDownloads,
|
||||
renderPendingQueue: renderPendingQueue,
|
||||
renderCompletedDownloads: renderCompletedDownloads,
|
||||
renderFailedDownloads: renderFailedDownloads,
|
||||
updateButtonStates: updateButtonStates
|
||||
};
|
||||
})();
|
||||
161
src/server/web/static/js/queue/queue-socket-handler.js
Normal file
161
src/server/web/static/js/queue/queue-socket-handler.js
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* AniWorld - Queue Socket Handler Module
|
||||
*
|
||||
* Handles WebSocket events specific to the queue page.
|
||||
*
|
||||
* Dependencies: constants.js, websocket-client.js, ui-utils.js, queue-renderer.js, progress-handler.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.QueueSocketHandler = (function() {
|
||||
'use strict';
|
||||
|
||||
const WS_EVENTS = AniWorld.Constants.WS_EVENTS;
|
||||
|
||||
// Reference to queue app for data reloading
|
||||
let queueApp = null;
|
||||
|
||||
/**
|
||||
* Initialize socket handler
|
||||
* @param {Object} app - Reference to queue app
|
||||
*/
|
||||
function init(app) {
|
||||
queueApp = app;
|
||||
setupSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up WebSocket event handlers
|
||||
*/
|
||||
function setupSocketHandlers() {
|
||||
const socket = AniWorld.WebSocketClient.getSocket();
|
||||
if (!socket) {
|
||||
console.warn('Socket not available for handler setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', function() {
|
||||
AniWorld.UI.showToast('Connected to server', 'success');
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
AniWorld.UI.showToast('Disconnected from server', 'warning');
|
||||
});
|
||||
|
||||
// Queue update events - handle both old and new message types
|
||||
socket.on('queue_updated', function(data) {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data);
|
||||
});
|
||||
|
||||
socket.on('queue_status', function(data) {
|
||||
// New backend sends queue_status messages with nested structure
|
||||
if (data.status && data.statistics) {
|
||||
const queueData = {
|
||||
...data.status,
|
||||
statistics: data.statistics
|
||||
};
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(queueData);
|
||||
} else if (data.queue_status) {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data.queue_status);
|
||||
} else {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Download started events
|
||||
socket.on('download_started', function() {
|
||||
AniWorld.UI.showToast('Download queue started', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
|
||||
socket.on('queue_started', function() {
|
||||
AniWorld.UI.showToast('Download queue started', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
|
||||
// Download progress
|
||||
socket.on('download_progress', function(data) {
|
||||
console.log('Received download progress:', data);
|
||||
const success = AniWorld.ProgressHandler.updateDownloadProgress(data);
|
||||
if (!success && queueApp) {
|
||||
// Card not found, reload queue
|
||||
queueApp.loadQueueData();
|
||||
}
|
||||
});
|
||||
|
||||
// Download complete events
|
||||
const handleDownloadComplete = function(data) {
|
||||
const serieName = data.serie_name || data.serie || 'Unknown';
|
||||
const episode = data.episode || '';
|
||||
AniWorld.UI.showToast('Completed: ' + serieName + (episode ? ' - Episode ' + episode : ''), 'success');
|
||||
|
||||
// Clear pending progress updates
|
||||
const downloadId = data.item_id || data.download_id || data.id;
|
||||
if (downloadId) {
|
||||
AniWorld.ProgressHandler.clearPendingUpdate(downloadId);
|
||||
}
|
||||
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
};
|
||||
socket.on(WS_EVENTS.DOWNLOAD_COMPLETED, handleDownloadComplete);
|
||||
socket.on(WS_EVENTS.DOWNLOAD_COMPLETE, handleDownloadComplete);
|
||||
|
||||
// Download error events
|
||||
const handleDownloadError = function(data) {
|
||||
const message = data.error || data.message || 'Unknown error';
|
||||
AniWorld.UI.showToast('Download failed: ' + message, 'error');
|
||||
|
||||
// Clear pending progress updates
|
||||
const downloadId = data.item_id || data.download_id || data.id;
|
||||
if (downloadId) {
|
||||
AniWorld.ProgressHandler.clearPendingUpdate(downloadId);
|
||||
}
|
||||
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
};
|
||||
socket.on(WS_EVENTS.DOWNLOAD_ERROR, handleDownloadError);
|
||||
socket.on(WS_EVENTS.DOWNLOAD_FAILED, handleDownloadError);
|
||||
|
||||
// Queue completed events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_QUEUE_COMPLETED, function() {
|
||||
AniWorld.UI.showToast('All downloads completed!', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.QUEUE_COMPLETED, function() {
|
||||
AniWorld.UI.showToast('All downloads completed!', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
|
||||
// Download stop requested
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STOP_REQUESTED, function() {
|
||||
AniWorld.UI.showToast('Stopping downloads...', 'info');
|
||||
});
|
||||
|
||||
// Queue stopped events
|
||||
const handleQueueStopped = function() {
|
||||
AniWorld.UI.showToast('Download queue stopped', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
};
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STOPPED, handleQueueStopped);
|
||||
socket.on(WS_EVENTS.QUEUE_STOPPED, handleQueueStopped);
|
||||
|
||||
// Queue paused/resumed
|
||||
socket.on(WS_EVENTS.QUEUE_PAUSED, function() {
|
||||
AniWorld.UI.showToast('Queue paused', 'info');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
|
||||
socket.on(WS_EVENTS.QUEUE_RESUMED, function() {
|
||||
AniWorld.UI.showToast('Queue resumed', 'success');
|
||||
if (queueApp) queueApp.loadQueueData();
|
||||
});
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init
|
||||
};
|
||||
})();
|
||||
120
src/server/web/static/js/shared/api-client.js
Normal file
120
src/server/web/static/js/shared/api-client.js
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* AniWorld - API Client Module
|
||||
*
|
||||
* HTTP request wrapper with automatic authentication
|
||||
* and error handling.
|
||||
*
|
||||
* Dependencies: constants.js, auth.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ApiClient = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Make an authenticated HTTP request
|
||||
* Automatically includes Authorization header and handles 401 responses
|
||||
*
|
||||
* @param {string} url - The API endpoint URL
|
||||
* @param {Object} options - Fetch options (method, headers, body, etc.)
|
||||
* @returns {Promise<Response|null>} The fetch response or null if auth failed
|
||||
*/
|
||||
async function request(url, options) {
|
||||
options = options || {};
|
||||
|
||||
// Get JWT token from localStorage
|
||||
const token = AniWorld.Auth.getToken();
|
||||
|
||||
// Check if token exists
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build request options with auth header
|
||||
const requestOptions = {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
// Add Content-Type for JSON body if not already set
|
||||
if (options.body && typeof options.body === 'string' && !requestOptions.headers['Content-Type']) {
|
||||
requestOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token is invalid or expired, clear it and redirect to login
|
||||
AniWorld.Auth.removeToken();
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
* @param {string} url - The API endpoint URL
|
||||
* @param {Object} headers - Additional headers
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async function get(url, headers) {
|
||||
return request(url, { method: 'GET', headers: headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request with JSON body
|
||||
* @param {string} url - The API endpoint URL
|
||||
* @param {Object} data - The data to send
|
||||
* @param {Object} headers - Additional headers
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async function post(url, data, headers) {
|
||||
return request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
* @param {string} url - The API endpoint URL
|
||||
* @param {Object} headers - Additional headers
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async function del(url, headers) {
|
||||
return request(url, { method: 'DELETE', headers: headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request with JSON body
|
||||
* @param {string} url - The API endpoint URL
|
||||
* @param {Object} data - The data to send
|
||||
* @param {Object} headers - Additional headers
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async function put(url, data, headers) {
|
||||
return request(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
request: request,
|
||||
get: get,
|
||||
post: post,
|
||||
delete: del,
|
||||
put: put
|
||||
};
|
||||
})();
|
||||
173
src/server/web/static/js/shared/auth.js
Normal file
173
src/server/web/static/js/shared/auth.js
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* AniWorld - Authentication Module
|
||||
*
|
||||
* Handles user authentication, token management,
|
||||
* and session validation.
|
||||
*
|
||||
* Dependencies: constants.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.Auth = (function() {
|
||||
'use strict';
|
||||
|
||||
const STORAGE = AniWorld.Constants.STORAGE_KEYS;
|
||||
const API = AniWorld.Constants.API;
|
||||
|
||||
/**
|
||||
* Get the stored access token
|
||||
* @returns {string|null} The access token or null if not found
|
||||
*/
|
||||
function getToken() {
|
||||
return localStorage.getItem(STORAGE.ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the access token
|
||||
* @param {string} token - The access token to store
|
||||
*/
|
||||
function setToken(token) {
|
||||
localStorage.setItem(STORAGE.ACCESS_TOKEN, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored access token
|
||||
*/
|
||||
function removeToken() {
|
||||
localStorage.removeItem(STORAGE.ACCESS_TOKEN);
|
||||
localStorage.removeItem(STORAGE.TOKEN_EXPIRES_AT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests
|
||||
* @returns {Object} Headers object with Authorization if token exists
|
||||
*/
|
||||
function getAuthHeaders() {
|
||||
const token = getToken();
|
||||
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* Redirects to login page if not authenticated
|
||||
* @returns {Promise<boolean>} True if authenticated, false otherwise
|
||||
*/
|
||||
async function checkAuth() {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
// Don't check authentication if already on login or setup pages
|
||||
if (currentPath === '/login' || currentPath === '/setup') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
console.log('checkAuthentication: token exists =', !!token);
|
||||
|
||||
if (!token) {
|
||||
console.log('checkAuthentication: No token found, redirecting to /login');
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
|
||||
const response = await fetch(API.AUTH_STATUS, { headers });
|
||||
console.log('checkAuthentication: response status =', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('checkAuthentication: Response not OK, status =', response.status);
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('checkAuthentication: data =', data);
|
||||
|
||||
if (!data.configured) {
|
||||
console.log('checkAuthentication: Not configured, redirecting to /setup');
|
||||
window.location.href = '/setup';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.authenticated) {
|
||||
console.log('checkAuthentication: Not authenticated, redirecting to /login');
|
||||
removeToken();
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('checkAuthentication: Authenticated successfully');
|
||||
|
||||
// Show logout button if it exists
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Authentication check failed:', error);
|
||||
removeToken();
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current user
|
||||
* @param {Function} showToast - Optional function to show toast messages
|
||||
*/
|
||||
async function logout(showToast) {
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.request(API.AUTH_LOGOUT, { method: 'POST' });
|
||||
|
||||
removeToken();
|
||||
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
if (showToast) {
|
||||
showToast(data.status === 'ok' ? 'Logged out successfully' : 'Logged out', 'success');
|
||||
}
|
||||
} else {
|
||||
if (showToast) {
|
||||
showToast('Logged out', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.href = '/login';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
removeToken();
|
||||
if (showToast) {
|
||||
showToast('Logged out', 'success');
|
||||
}
|
||||
setTimeout(function() {
|
||||
window.location.href = '/login';
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a valid token stored
|
||||
* @returns {boolean} True if token exists
|
||||
*/
|
||||
function hasToken() {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
getToken: getToken,
|
||||
setToken: setToken,
|
||||
removeToken: removeToken,
|
||||
getAuthHeaders: getAuthHeaders,
|
||||
checkAuth: checkAuth,
|
||||
logout: logout,
|
||||
hasToken: hasToken
|
||||
};
|
||||
})();
|
||||
147
src/server/web/static/js/shared/constants.js
Normal file
147
src/server/web/static/js/shared/constants.js
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* AniWorld - Constants Module
|
||||
*
|
||||
* Shared constants, API endpoints, and configuration values
|
||||
* used across all JavaScript modules.
|
||||
*
|
||||
* Dependencies: None (must be loaded first)
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.Constants = (function() {
|
||||
'use strict';
|
||||
|
||||
// API Endpoints
|
||||
const API = {
|
||||
// Auth endpoints
|
||||
AUTH_STATUS: '/api/auth/status',
|
||||
AUTH_LOGIN: '/api/auth/login',
|
||||
AUTH_LOGOUT: '/api/auth/logout',
|
||||
|
||||
// Anime endpoints
|
||||
ANIME_LIST: '/api/anime',
|
||||
ANIME_SEARCH: '/api/anime/search',
|
||||
ANIME_ADD: '/api/anime/add',
|
||||
ANIME_RESCAN: '/api/anime/rescan',
|
||||
ANIME_STATUS: '/api/anime/status',
|
||||
ANIME_SCAN_STATUS: '/api/anime/scan/status',
|
||||
|
||||
// Queue endpoints
|
||||
QUEUE_STATUS: '/api/queue/status',
|
||||
QUEUE_ADD: '/api/queue/add',
|
||||
QUEUE_START: '/api/queue/start',
|
||||
QUEUE_STOP: '/api/queue/stop',
|
||||
QUEUE_RETRY: '/api/queue/retry',
|
||||
QUEUE_REMOVE: '/api/queue', // + /{id}
|
||||
QUEUE_COMPLETED: '/api/queue/completed',
|
||||
QUEUE_FAILED: '/api/queue/failed',
|
||||
QUEUE_PENDING: '/api/queue/pending',
|
||||
|
||||
// Config endpoints
|
||||
CONFIG_DIRECTORY: '/api/config/directory',
|
||||
CONFIG_SECTION: '/api/config/section', // + /{section}
|
||||
CONFIG_BACKUP: '/api/config/backup',
|
||||
CONFIG_BACKUPS: '/api/config/backups',
|
||||
CONFIG_VALIDATE: '/api/config/validate',
|
||||
CONFIG_RESET: '/api/config/reset',
|
||||
|
||||
// Scheduler endpoints
|
||||
SCHEDULER_CONFIG: '/api/scheduler/config',
|
||||
SCHEDULER_TRIGGER: '/api/scheduler/trigger-rescan',
|
||||
|
||||
// Logging endpoints
|
||||
LOGGING_CONFIG: '/api/logging/config',
|
||||
LOGGING_FILES: '/api/logging/files',
|
||||
LOGGING_CLEANUP: '/api/logging/cleanup',
|
||||
LOGGING_TEST: '/api/logging/test',
|
||||
|
||||
// Diagnostics
|
||||
DIAGNOSTICS_NETWORK: '/api/diagnostics/network'
|
||||
};
|
||||
|
||||
// Local Storage Keys
|
||||
const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
TOKEN_EXPIRES_AT: 'token_expires_at',
|
||||
THEME: 'theme'
|
||||
};
|
||||
|
||||
// Default Values
|
||||
const DEFAULTS = {
|
||||
THEME: 'light',
|
||||
TOAST_DURATION: 5000,
|
||||
SCAN_AUTO_DISMISS: 3000,
|
||||
REFRESH_INTERVAL: 2000
|
||||
};
|
||||
|
||||
// WebSocket Rooms
|
||||
const WS_ROOMS = {
|
||||
DOWNLOADS: 'downloads',
|
||||
QUEUE: 'queue',
|
||||
SCAN: 'scan',
|
||||
SYSTEM: 'system',
|
||||
ERRORS: 'errors'
|
||||
};
|
||||
|
||||
// WebSocket Events
|
||||
const WS_EVENTS = {
|
||||
// Connection
|
||||
CONNECTED: 'connected',
|
||||
CONNECT: 'connect',
|
||||
DISCONNECT: 'disconnect',
|
||||
|
||||
// Scan events
|
||||
SCAN_STARTED: 'scan_started',
|
||||
SCAN_PROGRESS: 'scan_progress',
|
||||
SCAN_COMPLETED: 'scan_completed',
|
||||
SCAN_COMPLETE: 'scan_complete',
|
||||
SCAN_ERROR: 'scan_error',
|
||||
SCAN_FAILED: 'scan_failed',
|
||||
|
||||
// Scheduled scan events
|
||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
SCHEDULED_RESCAN_COMPLETED: 'scheduled_rescan_completed',
|
||||
SCHEDULED_RESCAN_ERROR: 'scheduled_rescan_error',
|
||||
SCHEDULED_RESCAN_SKIPPED: 'scheduled_rescan_skipped',
|
||||
|
||||
// Download events
|
||||
DOWNLOAD_STARTED: 'download_started',
|
||||
DOWNLOAD_PROGRESS: 'download_progress',
|
||||
DOWNLOAD_COMPLETED: 'download_completed',
|
||||
DOWNLOAD_COMPLETE: 'download_complete',
|
||||
DOWNLOAD_ERROR: 'download_error',
|
||||
DOWNLOAD_FAILED: 'download_failed',
|
||||
DOWNLOAD_PAUSED: 'download_paused',
|
||||
DOWNLOAD_RESUMED: 'download_resumed',
|
||||
DOWNLOAD_CANCELLED: 'download_cancelled',
|
||||
DOWNLOAD_STOPPED: 'download_stopped',
|
||||
DOWNLOAD_STOP_REQUESTED: 'download_stop_requested',
|
||||
|
||||
// Queue events
|
||||
QUEUE_UPDATED: 'queue_updated',
|
||||
QUEUE_STATUS: 'queue_status',
|
||||
QUEUE_STARTED: 'queue_started',
|
||||
QUEUE_STOPPED: 'queue_stopped',
|
||||
QUEUE_PAUSED: 'queue_paused',
|
||||
QUEUE_RESUMED: 'queue_resumed',
|
||||
QUEUE_COMPLETED: 'queue_completed',
|
||||
DOWNLOAD_QUEUE_COMPLETED: 'download_queue_completed',
|
||||
DOWNLOAD_QUEUE_UPDATE: 'download_queue_update',
|
||||
DOWNLOAD_EPISODE_UPDATE: 'download_episode_update',
|
||||
DOWNLOAD_SERIES_COMPLETED: 'download_series_completed',
|
||||
|
||||
// Auto download
|
||||
AUTO_DOWNLOAD_STARTED: 'auto_download_started',
|
||||
AUTO_DOWNLOAD_ERROR: 'auto_download_error'
|
||||
};
|
||||
|
||||
// Public API
|
||||
return {
|
||||
API: API,
|
||||
STORAGE_KEYS: STORAGE_KEYS,
|
||||
DEFAULTS: DEFAULTS,
|
||||
WS_ROOMS: WS_ROOMS,
|
||||
WS_EVENTS: WS_EVENTS
|
||||
};
|
||||
})();
|
||||
73
src/server/web/static/js/shared/theme.js
Normal file
73
src/server/web/static/js/shared/theme.js
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AniWorld - Theme Module
|
||||
*
|
||||
* Dark/light mode management and persistence.
|
||||
*
|
||||
* Dependencies: constants.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.Theme = (function() {
|
||||
'use strict';
|
||||
|
||||
const STORAGE = AniWorld.Constants.STORAGE_KEYS;
|
||||
const DEFAULTS = AniWorld.Constants.DEFAULTS;
|
||||
|
||||
/**
|
||||
* Initialize theme from saved preference
|
||||
*/
|
||||
function init() {
|
||||
const savedTheme = localStorage.getItem(STORAGE.THEME) || DEFAULTS.THEME;
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the application theme
|
||||
* @param {string} theme - 'light' or 'dark'
|
||||
*/
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(STORAGE.THEME, theme);
|
||||
|
||||
// Update theme toggle icon if it exists
|
||||
const themeIcon = document.querySelector('#theme-toggle i');
|
||||
if (themeIcon) {
|
||||
themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
function toggle() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || DEFAULTS.THEME;
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current theme
|
||||
* @returns {string} 'light' or 'dark'
|
||||
*/
|
||||
function getCurrentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') || DEFAULTS.THEME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dark mode is active
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDarkMode() {
|
||||
return getCurrentTheme() === 'dark';
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
setTheme: setTheme,
|
||||
toggle: toggle,
|
||||
getCurrentTheme: getCurrentTheme,
|
||||
isDarkMode: isDarkMode
|
||||
};
|
||||
})();
|
||||
245
src/server/web/static/js/shared/ui-utils.js
Normal file
245
src/server/web/static/js/shared/ui-utils.js
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* AniWorld - UI Utilities Module
|
||||
*
|
||||
* Toast notifications, loading overlays, and
|
||||
* common UI helper functions.
|
||||
*
|
||||
* Dependencies: constants.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.UI = (function() {
|
||||
'use strict';
|
||||
|
||||
const DEFAULTS = AniWorld.Constants.DEFAULTS;
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param {string} message - The message to display
|
||||
* @param {string} type - 'info', 'success', 'warning', or 'error'
|
||||
* @param {number} duration - Duration in milliseconds (optional)
|
||||
*/
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || DEFAULTS.TOAST_DURATION;
|
||||
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
console.warn('Toast container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.innerHTML =
|
||||
'<div style="display: flex; justify-content: space-between; align-items: center;">' +
|
||||
'<span>' + escapeHtml(message) + '</span>' +
|
||||
'<button onclick="this.parentElement.parentElement.remove()" ' +
|
||||
'style="background: none; border: none; color: var(--color-text-secondary); ' +
|
||||
'cursor: pointer; padding: 0; margin-left: 1rem;">' +
|
||||
'<i class="fas fa-times"></i>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto-remove after duration
|
||||
setTimeout(function() {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading overlay
|
||||
*/
|
||||
function showLoading() {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading overlay
|
||||
*/
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - The text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
* @param {number} bytes - Number of bytes
|
||||
* @param {number} decimals - Decimal places (default 2)
|
||||
* @returns {string} Formatted string like "1.5 MB"
|
||||
*/
|
||||
function formatBytes(bytes, decimals) {
|
||||
decimals = decimals || 2;
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable string
|
||||
* @param {number} seconds - Duration in seconds
|
||||
* @returns {string} Formatted string like "1h 30m"
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds || seconds <= 0) return '---';
|
||||
|
||||
if (seconds < 60) {
|
||||
return Math.round(seconds) + 's';
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return minutes + 'm';
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.round((seconds % 3600) / 60);
|
||||
return hours + 'h ' + minutes + 'm';
|
||||
} else {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.round((seconds % 86400) / 3600);
|
||||
return days + 'd ' + hours + 'h';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ETA (alias for formatDuration)
|
||||
* @param {number} seconds - ETA in seconds
|
||||
* @returns {string} Formatted ETA string
|
||||
*/
|
||||
function formatETA(seconds) {
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to locale string
|
||||
* @param {string|Date} date - Date to format
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for anime/series object
|
||||
* Returns name if available, otherwise key or folder
|
||||
* @param {Object} anime - Anime/series object
|
||||
* @returns {string} Display name
|
||||
*/
|
||||
function getDisplayName(anime) {
|
||||
if (!anime) return '';
|
||||
const name = anime.name || '';
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
return anime.key || anime.folder || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duration between two timestamps
|
||||
* @param {string} startTime - Start timestamp
|
||||
* @param {string} endTime - End timestamp
|
||||
* @returns {string} Formatted duration
|
||||
*/
|
||||
function calculateDuration(startTime, endTime) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const diffMs = end - start;
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
return minutes + 'm ' + seconds + 's';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a confirmation modal
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} message - Modal message
|
||||
* @returns {Promise<boolean>} Resolves to true if confirmed, false if cancelled
|
||||
*/
|
||||
function showConfirmModal(title, message) {
|
||||
return new Promise(function(resolve) {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
if (!modal) {
|
||||
resolve(window.confirm(message));
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('confirm-title').textContent = title;
|
||||
document.getElementById('confirm-message').textContent = message;
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
function handleConfirm() {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.getElementById('confirm-ok').removeEventListener('click', handleConfirm);
|
||||
document.getElementById('confirm-cancel').removeEventListener('click', handleCancel);
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('confirm-ok').addEventListener('click', handleConfirm);
|
||||
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the confirmation modal
|
||||
*/
|
||||
function hideConfirmModal() {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
showToast: showToast,
|
||||
showLoading: showLoading,
|
||||
hideLoading: hideLoading,
|
||||
escapeHtml: escapeHtml,
|
||||
formatBytes: formatBytes,
|
||||
formatDuration: formatDuration,
|
||||
formatETA: formatETA,
|
||||
formatDate: formatDate,
|
||||
getDisplayName: getDisplayName,
|
||||
calculateDuration: calculateDuration,
|
||||
showConfirmModal: showConfirmModal,
|
||||
hideConfirmModal: hideConfirmModal
|
||||
};
|
||||
})();
|
||||
164
src/server/web/static/js/shared/websocket-client.js
Normal file
164
src/server/web/static/js/shared/websocket-client.js
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* AniWorld - WebSocket Client Module
|
||||
*
|
||||
* WebSocket connection management and event handling.
|
||||
*
|
||||
* Dependencies: constants.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.WebSocketClient = (function() {
|
||||
'use strict';
|
||||
|
||||
const WS_EVENTS = AniWorld.Constants.WS_EVENTS;
|
||||
|
||||
let socket = null;
|
||||
let isConnected = false;
|
||||
let eventHandlers = {};
|
||||
|
||||
/**
|
||||
* Initialize WebSocket connection
|
||||
* @param {Object} handlers - Object mapping event names to handler functions
|
||||
*/
|
||||
function init(handlers) {
|
||||
handlers = handlers || {};
|
||||
eventHandlers = handlers;
|
||||
|
||||
// Check if Socket.IO is available
|
||||
if (typeof io === 'undefined') {
|
||||
console.error('Socket.IO not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
socket = io();
|
||||
|
||||
// Handle connection events
|
||||
socket.on('connected', function(data) {
|
||||
console.log('WebSocket connection confirmed', data);
|
||||
});
|
||||
|
||||
socket.on('connect', function() {
|
||||
isConnected = true;
|
||||
console.log('Connected to server');
|
||||
|
||||
// Subscribe to rooms
|
||||
if (socket.join) {
|
||||
socket.join('scan');
|
||||
socket.join('downloads');
|
||||
socket.join('queue');
|
||||
}
|
||||
|
||||
// Call custom connect handler if provided
|
||||
if (eventHandlers.onConnect) {
|
||||
eventHandlers.onConnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
isConnected = false;
|
||||
console.log('Disconnected from server');
|
||||
|
||||
// Call custom disconnect handler if provided
|
||||
if (eventHandlers.onDisconnect) {
|
||||
eventHandlers.onDisconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up event handlers for common events
|
||||
setupDefaultHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default event handlers
|
||||
*/
|
||||
function setupDefaultHandlers() {
|
||||
if (!socket) return;
|
||||
|
||||
// Register any events that have handlers
|
||||
Object.keys(eventHandlers).forEach(function(eventName) {
|
||||
if (eventName !== 'onConnect' && eventName !== 'onDisconnect') {
|
||||
socket.on(eventName, eventHandlers[eventName]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event handler
|
||||
* @param {string} eventName - The event name
|
||||
* @param {Function} handler - The handler function
|
||||
*/
|
||||
function on(eventName, handler) {
|
||||
if (!socket) {
|
||||
console.warn('Socket not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
eventHandlers[eventName] = handler;
|
||||
socket.off(eventName); // Remove existing handler
|
||||
socket.on(eventName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event handler
|
||||
* @param {string} eventName - The event name
|
||||
*/
|
||||
function off(eventName) {
|
||||
if (!socket) return;
|
||||
|
||||
delete eventHandlers[eventName];
|
||||
socket.off(eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to the server
|
||||
* @param {string} eventName - The event name
|
||||
* @param {*} data - The data to send
|
||||
*/
|
||||
function emit(eventName, data) {
|
||||
if (!socket || !isConnected) {
|
||||
console.warn('Socket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit(eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
* @returns {boolean} True if connected
|
||||
*/
|
||||
function getConnectionStatus() {
|
||||
return isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket instance
|
||||
* @returns {Object} The Socket.IO socket instance
|
||||
*/
|
||||
function getSocket() {
|
||||
return socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
function disconnect() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
on: on,
|
||||
off: off,
|
||||
emit: emit,
|
||||
isConnected: getConnectionStatus,
|
||||
getSocket: getSocket,
|
||||
disconnect: disconnect
|
||||
};
|
||||
})();
|
||||
@ -440,15 +440,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/websocket_client.js"></script>
|
||||
<script src="/static/js/localization.js"></script>
|
||||
<!-- Socket.IO -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
|
||||
|
||||
<!-- UX Enhancement Scripts -->
|
||||
<!-- Shared Modules (load in dependency order) -->
|
||||
<script src="/static/js/shared/constants.js"></script>
|
||||
<script src="/static/js/shared/auth.js"></script>
|
||||
<script src="/static/js/shared/api-client.js"></script>
|
||||
<script src="/static/js/shared/theme.js"></script>
|
||||
<script src="/static/js/shared/ui-utils.js"></script>
|
||||
<script src="/static/js/shared/websocket-client.js"></script>
|
||||
|
||||
<!-- External modules -->
|
||||
<script src="/static/js/localization.js"></script>
|
||||
<script src="/static/js/user_preferences.js"></script>
|
||||
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<!-- Index Page Modules -->
|
||||
<script src="/static/js/index/series-manager.js"></script>
|
||||
<script src="/static/js/index/selection-manager.js"></script>
|
||||
<script src="/static/js/index/search.js"></script>
|
||||
<script src="/static/js/index/scan-manager.js"></script>
|
||||
<!-- Config Sub-Modules (must load before config-manager.js) -->
|
||||
<script src="/static/js/index/scheduler-config.js"></script>
|
||||
<script src="/static/js/index/logging-config.js"></script>
|
||||
<script src="/static/js/index/advanced-config.js"></script>
|
||||
<script src="/static/js/index/main-config.js"></script>
|
||||
<script src="/static/js/index/config-manager.js"></script>
|
||||
<script src="/static/js/index/socket-handler.js"></script>
|
||||
<script src="/static/js/index/app-init.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -233,9 +233,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/websocket_client.js"></script>
|
||||
<script src="/static/js/queue.js"></script>
|
||||
<!-- Socket.IO -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
|
||||
|
||||
<!-- Shared Modules (load in dependency order) -->
|
||||
<script src="/static/js/shared/constants.js"></script>
|
||||
<script src="/static/js/shared/auth.js"></script>
|
||||
<script src="/static/js/shared/api-client.js"></script>
|
||||
<script src="/static/js/shared/theme.js"></script>
|
||||
<script src="/static/js/shared/ui-utils.js"></script>
|
||||
<script src="/static/js/shared/websocket-client.js"></script>
|
||||
|
||||
<!-- Queue Page Modules -->
|
||||
<script src="/static/js/queue/queue-api.js"></script>
|
||||
<script src="/static/js/queue/queue-renderer.js"></script>
|
||||
<script src="/static/js/queue/progress-handler.js"></script>
|
||||
<script src="/static/js/queue/queue-socket-handler.js"></script>
|
||||
<script src="/static/js/queue/queue-init.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -41,8 +41,9 @@ class TestCSSFileServing:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_contains_expected_variables(self, client):
|
||||
"""Test that styles.css contains expected CSS variables."""
|
||||
response = await client.get("/static/css/styles.css")
|
||||
"""Test that CSS variables are defined in base/variables.css."""
|
||||
# Variables are now in a separate module file
|
||||
response = await client.get("/static/css/base/variables.css")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
@ -56,22 +57,21 @@ class TestCSSFileServing:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_contains_dark_theme_support(self, client):
|
||||
"""Test that styles.css contains dark theme support."""
|
||||
response = await client.get("/static/css/styles.css")
|
||||
"""Test that dark theme support is in base/variables.css."""
|
||||
# Dark theme variables are now in a separate module file
|
||||
response = await client.get("/static/css/base/variables.css")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
# Check for dark theme variables
|
||||
assert '[data-theme="dark"]' in content
|
||||
assert "--color-bg-primary-dark:" in content
|
||||
assert "--color-text-primary-dark:" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_contains_responsive_design(self, client):
|
||||
"""Test that CSS files contain responsive design media queries."""
|
||||
# Test styles.css
|
||||
response = await client.get("/static/css/styles.css")
|
||||
# Responsive styles are now in utilities/responsive.css
|
||||
response = await client.get("/static/css/utilities/responsive.css")
|
||||
assert response.status_code == 200
|
||||
assert "@media" in response.text
|
||||
|
||||
@ -195,18 +195,29 @@ class TestCSSContentIntegrity:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_styles_css_structure(self, client):
|
||||
"""Test that styles.css has proper structure."""
|
||||
"""Test that styles.css is a modular entry point with @import statements."""
|
||||
response = await client.get("/static/css/styles.css")
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.text
|
||||
|
||||
# styles.css is now an entry point with @import statements
|
||||
assert "@import" in content
|
||||
|
||||
# Check for imports of base, components, pages, and utilities
|
||||
assert 'base/' in content or "base" in content.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_variables_file_structure(self, client):
|
||||
"""Test that base/variables.css has proper structure."""
|
||||
response = await client.get("/static/css/base/variables.css")
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.text
|
||||
|
||||
# Should have CSS variable definitions
|
||||
assert ":root" in content
|
||||
|
||||
# Should have base element styles
|
||||
assert "body" in content or "html" in content
|
||||
|
||||
# Should not have syntax errors (basic check)
|
||||
# Count braces - should be balanced
|
||||
open_braces = content.count("{")
|
||||
@ -229,12 +240,17 @@ class TestCSSContentIntegrity:
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_file_sizes_reasonable(self, client):
|
||||
"""Test that CSS files are not empty and have reasonable sizes."""
|
||||
# Test styles.css
|
||||
# Test styles.css (now just @imports, so smaller)
|
||||
response = await client.get("/static/css/styles.css")
|
||||
assert response.status_code == 200
|
||||
assert len(response.text) > 1000, "styles.css seems too small"
|
||||
assert len(response.text) > 100, "styles.css seems too small"
|
||||
assert len(response.text) < 500000, "styles.css seems unusually large"
|
||||
|
||||
# Test variables.css (has actual content)
|
||||
response = await client.get("/static/css/base/variables.css")
|
||||
assert response.status_code == 200
|
||||
assert len(response.text) > 500, "variables.css seems too small"
|
||||
|
||||
# Test ux_features.css
|
||||
response = await client.get("/static/css/ux_features.css")
|
||||
assert response.status_code == 200
|
||||
|
||||
@ -110,13 +110,18 @@ class TestTemplateIntegration:
|
||||
assert b"</html>" in content
|
||||
|
||||
async def test_templates_load_required_javascript(self, client):
|
||||
"""Test that index template loads all required JavaScript files."""
|
||||
"""Test that index template loads all required JavaScript modules."""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
content = response.content
|
||||
|
||||
# Check for main app.js
|
||||
assert b"/static/js/app.js" in content
|
||||
# Check for modular JS structure (shared modules)
|
||||
assert b"/static/js/shared/constants.js" in content
|
||||
assert b"/static/js/shared/auth.js" in content
|
||||
assert b"/static/js/shared/api-client.js" in content
|
||||
|
||||
# Check for index-specific modules
|
||||
assert b"/static/js/index/app-init.js" in content
|
||||
|
||||
# Check for localization.js
|
||||
assert b"/static/js/localization.js" in content
|
||||
@ -131,8 +136,8 @@ class TestTemplateIntegration:
|
||||
"""Test that queue template includes WebSocket support."""
|
||||
response = await client.get("/queue")
|
||||
assert response.status_code == 200
|
||||
# Check for websocket_client.js implementation
|
||||
assert b"websocket_client.js" in response.content
|
||||
# Check for modular websocket client
|
||||
assert b"/static/js/shared/websocket-client.js" in response.content
|
||||
|
||||
async def test_index_includes_search_functionality(self, client):
|
||||
"""Test that index page includes search functionality."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user