refactor: split CSS and JS into modular files (SRP)

This commit is contained in:
Lukas 2025-12-26 13:55:02 +01:00
parent 94cf36bff3
commit 2e5731b5d6
47 changed files with 8882 additions and 2298 deletions

View File

@ -101,6 +101,94 @@ src/server/
Source: [src/server/](../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/`) ### 2.3 Core Layer (`src/core/`)
Domain logic for anime series management. Domain logic for anime series management.

View File

@ -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 ### 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` - Server is running and functional before starting
2. Password: `Hallo123!` - All existing functionality works (login, index, queue pages)
3. Login via browser at `http://127.0.0.1:8000/login` - Backup current files before making changes
### 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
--- ---
## 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 **Deliverable**: A mental map of all functions, styles, and their relationships.
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
--- ---
### 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.

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}

View 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%;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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

View 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;
}

View 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;
}

View 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;
}
}

View 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
};
})();

View 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);
}
};

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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);
}
};

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View File

@ -440,15 +440,34 @@
</div> </div>
</div> </div>
<!-- Scripts --> <!-- Socket.IO -->
<script src="/static/js/websocket_client.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
<script src="/static/js/localization.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/user_preferences.js"></script>
<!-- Index Page Modules -->
<script src="/static/js/app.js"></script> <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> </body>
</html> </html>

View File

@ -233,9 +233,23 @@
</div> </div>
</div> </div>
<!-- Scripts --> <!-- Socket.IO -->
<script src="/static/js/websocket_client.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
<script src="/static/js/queue.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> </body>
</html> </html>

View File

@ -41,8 +41,9 @@ class TestCSSFileServing:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_css_contains_expected_variables(self, client): async def test_css_contains_expected_variables(self, client):
"""Test that styles.css contains expected CSS variables.""" """Test that CSS variables are defined in base/variables.css."""
response = await client.get("/static/css/styles.css") # Variables are now in a separate module file
response = await client.get("/static/css/base/variables.css")
assert response.status_code == 200 assert response.status_code == 200
content = response.text content = response.text
@ -56,22 +57,21 @@ class TestCSSFileServing:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_css_contains_dark_theme_support(self, client): async def test_css_contains_dark_theme_support(self, client):
"""Test that styles.css contains dark theme support.""" """Test that dark theme support is in base/variables.css."""
response = await client.get("/static/css/styles.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 assert response.status_code == 200
content = response.text content = response.text
# Check for dark theme variables # Check for dark theme variables
assert '[data-theme="dark"]' in content assert '[data-theme="dark"]' in content
assert "--color-bg-primary-dark:" in content
assert "--color-text-primary-dark:" in content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_css_contains_responsive_design(self, client): async def test_css_contains_responsive_design(self, client):
"""Test that CSS files contain responsive design media queries.""" """Test that CSS files contain responsive design media queries."""
# Test styles.css # Responsive styles are now in utilities/responsive.css
response = await client.get("/static/css/styles.css") response = await client.get("/static/css/utilities/responsive.css")
assert response.status_code == 200 assert response.status_code == 200
assert "@media" in response.text assert "@media" in response.text
@ -195,18 +195,29 @@ class TestCSSContentIntegrity:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_styles_css_structure(self, client): 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") response = await client.get("/static/css/styles.css")
assert response.status_code == 200 assert response.status_code == 200
content = response.text 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 # Should have CSS variable definitions
assert ":root" in content assert ":root" in content
# Should have base element styles
assert "body" in content or "html" in content
# Should not have syntax errors (basic check) # Should not have syntax errors (basic check)
# Count braces - should be balanced # Count braces - should be balanced
open_braces = content.count("{") open_braces = content.count("{")
@ -229,12 +240,17 @@ class TestCSSContentIntegrity:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_css_file_sizes_reasonable(self, client): async def test_css_file_sizes_reasonable(self, client):
"""Test that CSS files are not empty and have reasonable sizes.""" """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") response = await client.get("/static/css/styles.css")
assert response.status_code == 200 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" 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 # Test ux_features.css
response = await client.get("/static/css/ux_features.css") response = await client.get("/static/css/ux_features.css")
assert response.status_code == 200 assert response.status_code == 200

View File

@ -110,13 +110,18 @@ class TestTemplateIntegration:
assert b"</html>" in content assert b"</html>" in content
async def test_templates_load_required_javascript(self, client): 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("/") response = await client.get("/")
assert response.status_code == 200 assert response.status_code == 200
content = response.content content = response.content
# Check for main app.js # Check for modular JS structure (shared modules)
assert b"/static/js/app.js" in content 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 # Check for localization.js
assert b"/static/js/localization.js" in content assert b"/static/js/localization.js" in content
@ -131,8 +136,8 @@ class TestTemplateIntegration:
"""Test that queue template includes WebSocket support.""" """Test that queue template includes WebSocket support."""
response = await client.get("/queue") response = await client.get("/queue")
assert response.status_code == 200 assert response.status_code == 200
# Check for websocket_client.js implementation # Check for modular websocket client
assert b"websocket_client.js" in response.content assert b"/static/js/shared/websocket-client.js" in response.content
async def test_index_includes_search_functionality(self, client): async def test_index_includes_search_functionality(self, client):
"""Test that index page includes search functionality.""" """Test that index page includes search functionality."""