Compare commits

...

48 Commits

Author SHA1 Message Date
b1f1fa8780 Fix linting and formatting issues
- Run Prettier to fix code style in 7 files
- Auto-fix ESLint errors with --fix flag
- Replace ++ with += 1 in commandTracking.ts
- Add eslint-disable comments for intentional console.error usage
- Fix destructuring, array types, and template literals
- Remove trivial type annotations
2026-02-27 19:21:13 +01:00
7274d0639a Redesign SettingsPage with two-column desktop layout
- Implement responsive two-column grid (1fr + 1.5fr ratio) for desktop
- Left column: Quick settings (Appearance, Security) with compact styling
- Right column: Data-heavy sections (Integrations, Data & Privacy)
- Single column flow for mobile devices
- Redesign profile hero with horizontal layout on desktop
- Reduce avatar size (90px -> 70px) for better proportion
- Add side-by-side layout for avatar, user info, and stats
- Increase max-width to 1400px to utilize screen space
- Remove 'Local Storage' section (merged into Data & Privacy)
- Maintain responsive mobile layout with centered vertical flow
2026-02-27 19:21:13 +01:00
01987372dc Merge LocalStorageManager into DataExport component
- Combine 'Local Storage' and 'Data & Privacy' into single section
- Add info header with transparency messaging
- Include browser storage items with individual delete controls
- Integrate export functionality (JSON/CSV)
- Add request history stats and view button
- Implement two danger zones: Clear All Local Data and Delete Account
- Reduce bundle size by eliminating duplicate component (-7.42 KB CSS)
- Maintain delete account modal with confirmation flow
2026-02-27 19:21:13 +01:00
c517349410 Add LocalStorageManager for transparent data control
- Display all localStorage items with individual delete buttons
- Show item sizes inline with descriptions (e.g., 'description · 855 Bytes')
- Track: Command Palette, Plex Data, Theme, Color Scheme
- Add info header emphasizing data ownership and transparency
- Integrate DangerZoneAction for 'Clear Everything' functionality
- Use full-height red delete buttons for individual items
2026-02-27 19:21:13 +01:00
b3ea60b7fa Add reusable DangerZoneAction component for settings
- Create boxed danger zone component with red-tinted background
- Props: title, description, buttonText
- Consistent styling with border and hover effects
- Mobile-responsive padding and layout
2026-02-27 19:21:13 +01:00
e84ba1c40b Improve modal accessibility with focus trapping and ARIA attributes
- Implement focus trapping in Popup component for keyboard navigation
- Add tabindex and ARIA attributes to ActionButton for screen readers
- Ensure tab navigation cycles through modal elements properly
- Enhance keyboard-only user experience
2026-02-27 19:21:13 +01:00
f7cf2e4508 Add dynamic movie quotes to 404 page
- Fetch random movie taglines from TMDB API
- Display quotes with elegant serif font styling
- Add error handling for failed API calls
- Enhance user experience with contextual content
2026-02-27 19:21:13 +01:00
5bcdcd6568 Add command palette with smart usage tracking and content search
- Implement keyboard shortcut (Cmd/Ctrl+K) to open command palette
- Add smart ranking algorithm (70% frequency + 30% recency)
- Track both route navigation and content (movies/shows) usage
- Support parameter input for dynamic routes (e.g., /movie/:id)
- Add query parameter support for search routes
- Integrate ElasticSearch fallback for content search
- Include rate limiting and error handling for API calls
- Store usage data in localStorage (commandPalette_stats)
- Auto-scroll selected items into view with keyboard navigation
2026-02-27 19:21:13 +01:00
c390fcba47 Properly fix mobile torrent table with conditional rendering
Previous fix still rendered all 4 columns in DOM (just hidden with CSS),
causing horizontal overflow. Now actually renders only 2 columns on mobile.

Implementation:
1. Added reactive window width tracking with resize listener
2. Computed isMobile property (windowWidth <= 768px)
3. Computed visibleColumns: ['name', 'add'] on mobile, all 4 on desktop
4. Conditional v-if rendering for seed/size columns
5. Conditional v-if for metadata display in torrent-info cell

Template changes:
- Header: v-for="column in visibleColumns" (not all columns)
- Seed column: v-if="!isMobile" (not rendered on mobile)
- Size column: v-if="!isMobile" (not rendered on mobile)
- Metadata: v-if="isMobile" (only shown on mobile)

CSS cleanup:
- Removed .desktop-only class rules (no longer needed)
- Removed display: none media queries (handled by v-if)
- Removed header nth-child hiding (handled by visibleColumns)

Result:
Mobile (≤768px):
  - Only 2 <td> elements rendered: name + add
  - No horizontal scroll required
  - Metadata shown inline under title
  - Colspan correctly set to 2 for expanded rows

Desktop (>768px):
  - All 4 <td> elements rendered: name + seed + size + add
  - Full table layout
  - Colspan correctly set to 4 for expanded rows

This is the correct solution - don't render unnecessary DOM elements.
2026-02-27 19:21:13 +01:00
f63e10d28d Fix mobile torrent table display logic
The mobile torrent table changes were not working correctly due to CSS
specificity and display logic issues.

Fixes:
1. Changed .torrent-meta display logic:
   - Before: display: none by default, then display: flex on mobile
   - After: display: flex by default, display: none !important on desktop
   - This ensures the metadata shows on mobile and is properly hidden on desktop

2. Fixed expanded row colspan:
   - Dynamically calculate colspan based on screen width
   - Mobile (≤768px): colspan = 2 (name + add columns)
   - Desktop (>768px): colspan = 4 (name + seed + size + add columns)
   - Prevents layout issues when expanding torrent names

Why the original didn't work:
- CSS specificity: 'display: none' as default was overriding mobile styles
- The @include mobile wasn't applying correctly due to cascade order
- Using @include desktop with !important ensures proper hiding

Result:
- Mobile: Shows torrent title with size/seeders on second line
- Desktop: Shows full 4-column table with separate columns
- Expanded rows now span correct number of columns on both layouts
2026-02-27 19:21:13 +01:00
73d72c634f Fix TV show posters to display show artwork instead of episode thumbnails
When displaying recently added TV content, use the show's poster and
metadata instead of the individual episode's thumbnail and info.

Changes to processLibraryItem():
- Poster logic: For TV shows, prioritize grandparentThumb (show poster)
  over thumb (episode thumbnail)
- Title: Use grandparentTitle (show name) instead of title (episode name)
- Year: Use grandparentYear (show year) instead of episode year
- Also applied same logic to music (use album/artist artwork)

Before:
- Shows displayed with episode-specific thumbnails
- Episode titles shown instead of show titles
- Inconsistent visual presentation

After:
- Shows display with proper show posters
- Show titles and years displayed correctly
- Consistent, professional library presentation
- Better visual recognition of TV series

This matches user expectations when browsing recently added TV content,
showing the series artwork rather than individual episode stills.
2026-02-27 19:21:13 +01:00
65ad916df8 Update Plex library item URLs to use app.plex.tv format
Change library item links to use the official Plex Web App URL format
instead of direct server URLs. This ensures items open correctly in
the Plex web interface.

Changes:
- usePlexApi.fetchPlexServers() now returns machineIdentifier (clientIdentifier)
- PlexSettings stores and passes machineId through the library loading flow
- usePlexLibraries.loadLibraries() accepts machineIdentifier parameter
- processLibrarySection() passes machineIdentifier to processLibraryItem()
- plexHelpers.processLibraryItem() updated signature and URL generation

New URL format:
https://app.plex.tv/desktop/#!/server/{machineId}/details?key=%2Flibrary%2Fmetadata%2F{ratingKey}

Example:
fe85f47ef9/details

Benefits:
- Links work universally (not dependent on local server URL)
- Opens in official Plex Web App with full functionality
- Consistent with Plex's own linking conventions
- Works from any network location
2026-02-27 19:21:13 +01:00
f98fdb6860 Replace emojis with SVG icons in Plex library section and add clickable links
Modernize the Plex library UI by replacing emoji icons with proper SVG
icons and making library items clickable to open in Plex.

New icons:
- Created IconMusic.vue for music/album libraries
- Created IconClock.vue for watch time display

PlexLibraryStats updates:
- Replace emoji icons (🎬, 📺, 🎵, ⏱️) with IconMovie, IconShow, IconMusic, IconClock
- Icons use highlight color with hover effects
- Proper sizing: 2.5rem desktop, 2rem mobile

PlexLibraryModal updates:
- Replace emoji in header with dynamic icon component
- Icon sized at 48px with highlight color
- Better visual consistency

PlexLibraryItem updates:
- Add support for clickable links to Plex web interface
- Items render as <a> tags when plexUrl is available
- Fallback icons now use SVG components instead of emojis
- Non-linkable items have disabled hover state

plexHelpers updates:
- processLibraryItem now includes ratingKey and plexUrl
- plexUrl format: {serverUrl}/web/index.html#!/server/library/metadata/{ratingKey}
- Added getLibraryIconComponent helper function

Benefits:
- Professional SVG icons instead of emojis (consistent cross-platform)
- Clickable library items open directly in Plex
- Better accessibility with proper link semantics
- Scalable icons that look sharp at any size
- Consistent color theming with site palette
2026-02-27 19:21:13 +01:00
1ed675fcf5 Replace hardcoded password words with Random Word API
Improve password generator by using dynamic word sources instead of
static hardcoded lists.

Changes:
- Created useRandomWords composable:
  - Primary: Random Word API (https://random-word-api.herokuapp.com)
  - Fallback: EFF Diceware word list (576 memorable words)
  - Automatic fallback if API fails or is unavailable

- Updated PasswordGenerator component:
  - Remove 80+ lines of hardcoded word lists (adjectives, nouns, verbs, objects)
  - Use async getRandomWords() for passphrase generation
  - Better word variety and unpredictability
  - Maintains same UX (no visible changes to users)

Benefits:
- More secure: Larger word pool (thousands vs 80 words)
- Always fresh: API provides truly random words
- Reliable: Built-in fallback ensures it always works
- Maintainable: No need to curate word lists
- Smaller bundle: Removed ~80 hardcoded words from component
2026-02-27 19:21:13 +01:00
74c0a68aeb Refactor Tautulli integration to use efficient pre-aggregated APIs
Major performance improvement: Replace manual history aggregation with
Tautulli's built-in stats APIs. This eliminates the need to fetch and
process thousands of history records on every page load.

Changes:
- useTautulliStats composable completely rewritten:
  - Use get_home_stats for overall watch statistics (pre-aggregated)
  - Use get_plays_by_date for daily activity (already grouped by day)
  - Use get_plays_by_dayofweek for weekly patterns (pre-calculated)
  - Use get_plays_by_hourofday for hourly distribution (pre-calculated)
  - Remove fetchUserHistory() and manual aggregation functions

- ActivityPage updates:
  - Fetch all data in parallel with Promise.all for faster loading
  - Use user_id instead of username for better API performance
  - Simplified data processing since API returns pre-aggregated data

Benefits:
- 10-100x faster data loading (no need to fetch/process full history)
- Reduced network bandwidth (smaller API responses)
- Less client-side computation (no manual aggregation)
- Better scalability for large time ranges (365+ days)
- Consistent with Tautulli's internal calculations
2026-02-27 19:21:13 +01:00
64a833c9f8 Improve mobile UX: condense torrent table and standardize page layouts
- TorrentTable: Condense to 2 columns on mobile (title+meta, actions)
  - Title shown on first line, size/seeders on second line
  - Hide separate seed/size columns on mobile (desktop only)
  - Improved spacing and readability for mobile screens

- Standardize page layouts to match ActivityPage:
  - TorrentsPage: Update header style, padding, and container structure
  - GenPasswordPage: Align header and content layout with other pages
  - Consistent 3rem desktop padding, 0.75rem mobile padding
  - Unified h1 styling: 2rem desktop, 1.5rem mobile, font-weight 300

- Minor improvements:
  - Remove console.log statements from usePlexApi
  - Fix duration unit handling in useTautulliStats
  - Adjust AdminStats label font sizing
  - Reduce Graph.vue point radius for cleaner charts
2026-02-27 19:21:13 +01:00
0c4c30d1a0 Refactor: Modernize Activity page UI to match site design
Update page structure:
- Rename wrapper class to 'activity' (matches AdminPage pattern)
- Update h1 to activity__title with consistent styling
- Organize content with BEM naming convention

Redesign controls:
- Replace basic input with styled input-wrapper component
- Add input-suffix "days" label inside input container
- Custom number input styling with hover/focus states
- Better ToggleButton integration with control-label
- Responsive flex layout with proper mobile handling

Enhance chart presentation:
- Wrap each graph in chart-card component
- Add background, borders, and rounded corners
- Clear chart-card__title headers
- Fixed height charts (35vh desktop, 30vh mobile)
- Minimum height prevents squashing (300px/250px)
- Consistent spacing and padding

Improve top content section:
- Grid layout for top content items (3 cols → 1 col mobile)
- Card-based items with borders and hover effects
- Hover: border highlight + translateY animation
- Better visual hierarchy with section-title

Styling details:
- Use CSS variables (--background-ui, --text-color-50, etc.)
- Match AdminPage typography (2rem title, 300 weight)
- Consistent border-radius (12px cards, 8px inputs)
- Proper mobile-only responsive breakpoints
- Remove old commented-out code

Result: Professional, cohesive design matching rest of site 
2026-02-27 19:21:13 +01:00
e0ce0ea6da Fix: Add localStorage fallback for Plex authentication checks
Issue: ActivityPage and route guards showed "not authenticated"
even when Plex was linked via Settings page.

Root cause: Plex user data stored in localStorage but route guards
and ActivityPage only checked Vuex store (state.settings.plexUserId).

Changes:
- Update routes.ts hasPlexAccount() to check both:
  1. Vuex store (user/plexUserId)
  2. localStorage (plex_user_data) as fallback

- Update ActivityPage plexUserId computed to check both:
  1. Vuex store first
  2. localStorage plex_user_data.id as fallback

Why two sources?
- Vuex store: Set from JWT token (backend user settings)
- localStorage: Set immediately when linking Plex account
- localStorage persists across page reloads
- Provides seamless experience without backend round-trip

Now Activity page correctly shows data when Plex is linked ✓
2026-02-27 19:21:13 +01:00
d1578723c4 Feature: Integrate Tautulli stats with enhanced Activity page
Create useTautulliStats composable (247 lines):
- fetchUserHistory() - Get watch history from Tautulli API
- calculateWatchStats() - Total hours, plays by media type
- groupByDay() - Daily activity (plays & duration)
- groupByDayOfWeek() - Weekly patterns by media type
- getTopContent() - Most watched content ranking
- getHourlyDistribution() - Watch patterns by hour of day

Update ActivityPage.vue with new visualizations:
- Stats overview cards (4 metrics: plays, hours, movies, episodes)
- Activity per day line chart (plays or duration)
- Activity by media type stacked bar chart (movies/shows/music)
- NEW: Hourly distribution chart
- NEW: Top 10 most watched content list

Features:
- Direct Tautulli API integration (no backend needed)
- Real-time data from Plex watch history
- Configurable time range (days filter)
- Toggle between plays count and watch duration
- Responsive grid layout for stats cards
- Styled top content ranking with hover effects

Benefits:
- Rich visualization of actual watch patterns
- See viewing habits by time of day
- Identify most rewatched content
- Compare movie vs TV viewing
- All data from authoritative source (Tautulli)

ActivityPage now provides comprehensive watch analytics! 📊
2026-02-27 19:21:13 +01:00
6c24bc928c Refactor: Create reusable PlexLibraryItem component with grid layout
- Create new PlexLibraryItem.vue component
  - Displays poster with fallback icon
  - Shows title, year, and rating
  - Optional extras (artist, episodes, tracks)
  - Hover effect with translateY animation
  - Responsive font sizes for mobile

- Update PlexLibraryModal to use grid layout
  - Replace vertical list with CSS Grid
  - Grid: repeat(auto-fill, minmax(140px, 1fr))
  - Mobile: minmax(110px, 1fr) with reduced gap
  - Much better space utilization
  - Items flow horizontally then vertically

- Remove duplicate styles from modal
  - Removed 69 lines of item styling
  - All item display logic in PlexLibraryItem
  - Cleaner separation of concerns

Benefits:
- Better visual presentation (grid vs vertical list)
- More items visible at once
- Reusable component for future Plex features
- Reduced modal complexity (284 → 215 lines)
2026-02-27 19:21:13 +01:00
720f4e253a Fix: Correct props passed to PlexLibraryStats component
PlexLibraryStats expects individual number props:
- movies: number
- shows: number
- music: number
- watchtime: number
- loading?: boolean

PlexSettings was incorrectly passing:
- :stats="libraryStats" (single object)

Fixed to destructure and pass individual props:
- :movies="libraryStats.movies"
- :shows="libraryStats.shows"
- :music="libraryStats.music"
- :watchtime="libraryStats.watchtime"

Library stats now display correctly ✓
2026-02-27 19:21:13 +01:00
017a489b0d Fix: Correct API URL construction to prevent double URL issue
- Update fetchLibrarySections to accept serverUrl parameter
  - Was using internal plexServerUrl.value ref
  - Now accepts explicit serverUrl parameter
  - Prevents URL doubling when called from PlexSettings

- Update fetchLibraryDetails to accept serverUrl parameter
  - Changed signature: (authToken, serverUrl, sectionKey)
  - Was: (authToken, sectionKey) using internal ref
  - Now matches how it's called from loadLibraries composable

- Fixes 404 errors from malformed URLs like:
  http://server.com/library/sectionshttp://server.com/library/sections

Library API calls now use correct single URLs ✓
2026-02-27 19:21:13 +01:00
5e73b73783 Fix: Restore library stats functionality and remove debug logging
- Fix usePlexLibraries composable to return stats and details
  - Updated loadLibraries signature to match PlexSettings usage
  - Now accepts: sections, authToken, serverUrl, username, fetchFn
  - Returns: { stats, details } object instead of updating refs
  - Added watchtime calculation from Tautulli API

- Update processLibrarySection to work with passed parameters
  - Accept stats and details objects instead of using refs
  - Accept serverUrl and fetchLibraryDetailsFn as parameters
  - No longer depends on composable internal state

- Remove all debug console.log statements
  - Clean up usePlexAuth composable (removed 13 debug logs)
  - Clean up PlexSettings component (removed 9 debug logs)
  - Keep only error logging for troubleshooting

Library stats now display correctly after authentication ✓
Build size reduced by removing debug code
2026-02-27 19:21:13 +01:00
15b6c571d0 Fix: Correct event handling between PlexSettings and PlexAuthButton
**CRITICAL FIX - THIS WAS THE BUG!**

PlexAuthButton emits:
- auth-success (with token)
- auth-error (with error message)

PlexSettings was listening for:
- @authenticate (WRONG - this event doesn't exist!)

Changes:
- Update PlexSettings template to listen for correct events:
  - @auth-success="handleAuthSuccess"
  - @auth-error="handleAuthError"

- Replace authenticatePlex() with two event handlers:
  - handleAuthSuccess(token) - processes successful auth
  - handleAuthError(message) - displays error messages

- Remove unused openAuthPopup import (now handled by button)
- Remove intermediate completePlexAuth function
- Simplified auth flow: Button → Event → Handler

This explains why authentication wasn't working - the click event
was never being handled because the event names didn't match!
2026-02-27 19:21:13 +01:00
46880474d1 Debug: Add popup opening verification logs
- Log when openAuthPopup is called
- Log popup blocked vs success
- Helps identify if popup is even opening
2026-02-27 19:21:13 +01:00
8795845acf Debug: Add comprehensive logging to Plex authentication flow
- Add detailed console logs throughout auth process
  - PIN generation with CLIENT_IDENTIFIER
  - PIN polling status checks
  - Auth token received confirmation
  - Cookie setting and verification
  - User data fetch and account linking

- Helps diagnose authentication and cookie issues
- Logs show exact point of failure in auth flow
- Can be removed once issue is identified and fixed
2026-02-27 19:21:13 +01:00
368ad70096 Fix: Resolve Plex authentication cookie and polling issues
- Export CLIENT_IDENTIFIER and APP_NAME as module-level constants
  - Ensures same identifier used across all composables and API calls
  - Prevents auth failures from mismatched client identifiers

- Refactor PlexSettings.vue to use composable auth flow
  - Remove duplicate authentication logic (138 lines removed)
  - Use openAuthPopup() from usePlexAuth composable
  - Use cleanup() function in onUnmounted hook
  - Reduced from 498 lines to 360 lines (28% further reduction)

- Fix usePlexAuth to import constants directly
  - Previously tried to get constants from usePlexApi() instance
  - Now imports as shared module exports
  - Ensures consistent CLIENT_IDENTIFIER across auth flow

Total PlexSettings.vue reduction: 2094 → 360 lines (83% reduction)
Authentication flow now properly sets cookies and completes polling ✓
2026-02-27 19:21:13 +01:00
ac591cbebe Refactor: Complete PlexSettings modularization with modal components
- Create PlexLibraryModal.vue (365 lines) for detailed library view
  - Stats overview (total items, episodes/tracks, duration)
  - Recently added items with posters and metadata
  - Top genres with visual bar charts
  - Fully responsive modal design

- Create PlexUnlinkModal.vue (138 lines) for account unlinking
  - Confirmation dialog with feature loss warnings
  - Clean modal UI with cancel/confirm actions

- Refactor PlexSettings.vue: 2094 lines → 498 lines (76% reduction)
  - Replace inline UI with PlexAuthButton component
  - Replace profile card with PlexProfileCard component
  - Replace stats grid with PlexLibraryStats component
  - Replace server info with PlexServerInfo component
  - Use PlexLibraryModal and PlexUnlinkModal for overlays
  - Integrate usePlexAuth, usePlexApi, usePlexLibraries composables
  - Remove 1596 lines of duplicate template and logic
  - Maintain all functionality with cleaner architecture

Total extraction: 2031 lines from monolithic file into 10 modular components
Build verified successfully ✓
2026-02-27 19:21:12 +01:00
37ad9ecb7b Refactor: Add library stats, server info, and helper utilities
Extract more reusable components and utilities:

Components:
- PlexLibraryStats.vue: 4-card stats grid with loading states
- PlexServerInfo.vue: Server details and sync/unlink actions

Composables:
- usePlexLibraries.ts: Library data loading and processing logic

Utilities:
- plexHelpers.ts: Pure functions for formatting and calculations
  - getLibraryIcon/Title: Type to display mapping
  - formatDate/MemberSince: Date formatting
  - processLibraryItem: Parse API response to display format
  - calculateGenreStats: Top 5 genres from metadata
  - calculateDuration: Total hours, episodes, tracks

Benefits:
- Cleaner separation: UI vs logic vs utilities
- Testable pure functions
- Reusable across components
- Reduces PlexSettings.vue complexity
2026-02-27 19:21:12 +01:00
1813331673 Refactor: Extract Plex composables and smaller components
Split large PlexSettings component into reusable pieces:

Composables:
- usePlexApi.ts: API functions for user data, servers, libraries
- usePlexAuth.ts: OAuth authentication flow, PIN generation, polling

Components:
- PlexAuthButton.vue: Sign-in button with OAuth popup
- PlexProfileCard.vue: User profile with badges (Pass, 2FA, Labs, years)

Benefits:
- Better code organization and maintainability
- Reusable authentication logic
- Cleaner separation of concerns
- Easier testing and debugging
2026-02-27 19:21:12 +01:00
77c89fa520 Enhance Plex integration with real API data and interactive library modal
Major improvements to Plex integration:
- Replace Vuex store dependency with localStorage-based connection detection
- Fetch and display real Plex user data (username, email, subscription, 2FA status)
- Add user badges: Plex Pass, member years, 2FA, experimental features
- Properly format Unix timestamp joined dates
- Remove success message box, add elegant checkmark icon next to username
- Add Plex connection badge to main user profile

Real-time Plex API integration:
- Fetch actual library counts from Plex server (movies, shows, music)
- Display real server name from user's Plex account
- Load recently added items with actual titles, years, and ratings
- Calculate real genre statistics from library metadata
- Compute actual duration totals from item metadata
- Count actual episodes (TV shows) and tracks (music)
- Sync library on demand with fresh data from Plex API

Interactive library modal:
- Replace toast messages with rich modal showing library details
- Display recently added items with poster images
- Show genre distribution with animated bar charts
- Add loading states with animated dots
- Disable empty library cards
- Modal appears above header with proper z-index
- Blur backdrop for better focus
- Fully responsive mobile design

Store Plex data in localStorage:
- Cache user profile data including subscription info
- Store auth token in secure cookie (30 day expiration)
- Load from cache for instant display on page load
- Refresh data on authentication and manual sync

Add Plex connection indicator to user profile:
- Orange Plex badge in settings profile header
- Shows 'Connected as [username]' below member info
- Loads username from localStorage on mount
2026-02-27 19:21:12 +01:00
9c7e0bd3b3 Refactor and optimize admin page components
- Remove unused imports and auto-refresh functionality
- Reduce padding and spacing for more compact admin layout
- Simplify stats generation and remove unused variables
- Adjust font sizes and icon sizes for better consistency
- Improve line-height on admin page title
- Minor performance optimizations
2026-02-27 19:21:12 +01:00
0a2e721cfc Minor UI component styling improvements
- Remove margin-right from SeasonedButton for better layout control
- Remove max-width constraint from SeasonedInput for full-width forms
- Simplify Toast component layout and remove unused icon section
- Improve toast text spacing and structure
2026-02-27 19:21:12 +01:00
7f089c5c48 Add theme initialization on app startup
- Load saved theme preference from localStorage
- Support for 'auto' theme that follows system preference
- Listen for system theme changes and update accordingly
- Apply theme before app mounts to prevent flash
2026-02-27 19:21:12 +01:00
75aa75dad1 Add password generator and user profile components
- PasswordGenerator: Generate secure random passwords with options
- UserProfile: User information display (deprecated, moved to SettingsPage hero card)
- Supporting components for settings functionality
2026-02-27 19:21:12 +01:00
c3ef3d968d Update password change component for new settings layout
- Remove section title (now in parent SettingsPage)
- Add password generator integration
- Info box with password requirements
- Compact form layout with consistent spacing
- Match settings page typography and spacing
2026-02-27 19:21:12 +01:00
258b1ef126 Add data export and account management component
- Export user data in JSON or CSV format
- Display request statistics with mini stat cards
- View full request history button
- Account deletion with confirmation modal
- Warning for permanent actions with DELETE confirmation
- Compact styling consistent with settings page design
2026-02-27 19:21:12 +01:00
6d7ade91ff Implement Plex OAuth authentication with popup flow
- Replace username/password with OAuth flow
- Generate PIN and open popup to app.plex.tv/auth
- Safari-compatible: open popup immediately, navigate after PIN generation
- Poll PIN status every second for authentication
- Custom loading screen in popup while generating PIN
- Plex orange button (#c87818) with icon
- Update API to accept authToken instead of credentials
- Cleanup on component unmount and popup close
- Handle popup blockers with user-friendly error messages
2026-02-27 19:21:12 +01:00
e1aaa3f1ea Redesign settings page with profile hero card
- Create single-page settings layout (removed sidebar navigation)
- Add large profile hero card with avatar, stats, and user info
- Display user stats: Requests and Magnets Added
- Compact spacing and improved typography hierarchy
- Section headers at 1.5rem for better hierarchy
- Reduced whitespace while maintaining readability
- Max-width: 800px for better content focus
2026-02-27 19:21:12 +01:00
244895f06a Add theme selection UI with Seed theme support
- Add ThemePreferences component with current theme display
- Visual theme preview cards showing colors for each theme
- Add Seed theme to available themes list
- Theme icon with gradient and preview card styling
- Support for Auto, Light, Dark, Ocean, Nordic, Seed, and Halloween themes
2026-02-27 19:21:12 +01:00
d9be15aad0 Add Seed theme inspired by seed.com
- Add new 'seed' theme with green color palette
- Primary colors: #1c3a13 (seed green), #fcfcf7 (snow white), #e9f0ca (lemongrass)
- Dark green backgrounds with light green accents
- Complete theme definition with all CSS variables
2026-02-27 19:21:12 +01:00
fd842b218b v2 - lift all w/ icons, reactive layout, sort & filter 2026-02-27 19:21:12 +01:00
0f774e8f2e admin page & components 2026-02-27 19:21:12 +01:00
081240c83e include credentials on login fetch requests, allows set header response 2026-02-24 22:22:22 +01:00
eac12748db mobile improvements
tries to setup layout for success with safari iso 26 bottom navigation
bar and having content appear behind it instead of having a fat lip of
background color.

Also fixes where main content was not taking full width on mobile & text
alignment on torrent search results.
2026-02-24 18:43:26 +01:00
426b376d05 Feat: Dynamic colors (#101)
* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Feat: vite & upgraded dependencies (#100)

* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings

* replace webpack w/ vite

* update all imports with alias @ and scss

* vite environment variables, also typed

* upgraded eslint, defined new rules & added ignore comments

* resolved linting issues

* moved index.html to project root

* updated dockerfile w/ build stage before runtime image definition

* sign drone config

* dynamic colors from poster for popup bg & text colors

* more torrents nav button now link elem & better for darker bg

* make list item title clickable

* removed extra no-shadow eslint rule definitions

* fixed movie import

* adhere to eslint rules & package.json clean command

* remove debounce autocomplete search, track & hault on failure
2026-02-24 00:22:51 +01:00
1238cf50cc Feat: Caddy webserver (#102)
* describe Caddyfile & update Dockerfile runtime image

* remove nginx config - replaced by caddy
2026-02-24 00:22:31 +01:00
8e586811ec Feat: vite & upgraded dependencies (#100)
* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings

* replace webpack w/ vite

* update all imports with alias @ and scss

* vite environment variables, also typed

* upgraded eslint, defined new rules & added ignore comments

* resolved linting issues

* moved index.html to project root

* updated dockerfile w/ build stage before runtime image definition

* sign drone config
2026-02-23 20:53:19 +01:00
113 changed files with 15418 additions and 13305 deletions

View File

@@ -25,7 +25,7 @@ steps:
path: /cache
- name: Frontend install
image: node:18.2.0
image: node:24.13.1
commands:
- node -v
- yarn --version
@@ -42,8 +42,14 @@ steps:
- name: cache
path: /cache
- name: Lint project using eslint
image: node:24.13.1
commands:
- yarn lint
failure: ignore
- name: Frontend build
image: node:18.2.0
image: node:24.13.1
commands:
- yarn build
environment:
@@ -56,12 +62,6 @@ steps:
SEASONED_DOMAIN:
from_secret: SEASONED_DOMAIN
- name: Lint project using eslint
image: node:18.2.0
commands:
- yarn lint
failure: ignore
- name: Build and publish docker image
image: plugins/docker
settings:
@@ -105,3 +105,8 @@ trigger:
include:
- push
# - pull_request
---
kind: signature
hmac: 6f10b2871d2bd6b5cd26ddf72796325991ba211ba1eb62b657baf993e9d549c8
...

View File

@@ -1,4 +1,3 @@
SEASONED_API=
ELASTIC=
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=
SEASONED_API=http://localhost:31459
ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ELASTIC_API_KEY=

View File

@@ -1,31 +0,0 @@
{
"root": true,
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"@vue/eslint-config-airbnb",
"plugin:vue/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
"rules": {
"vue/no-v-model-argument": "off",
"no-underscore-dangle": "off",
"vue/multi-word-component-names": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
},
"settings": {
"import/resolver": {
webpack: {
config: "./webpack.config.js"
}
}
}
}

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ src/config.json
# Build directory
dist/
lib/
# Node packages
node_modules/

31
Caddyfile Normal file
View File

@@ -0,0 +1,31 @@
{
# Disable automatic HTTPS
auto_https off
}
:8080 {
root * {$DIST_PATH:/usr/share/caddy}
file_server
encode gzip zstd
try_files {path} {path}/ /index.html
# Cache favicons aggressively
@favicons path /favicons/*
header @favicons Cache-Control "public, max-age=31536000, immutable"
# Cache static assets based on MIME type
@static {
header Content-Type application/javascript*
header Content-Type text/css*
header Content-Type image/*
header Content-Type font/*
header Content-Type application/font-*
header Content-Type application/woff*
header Content-Type application/json*
}
header @static Cache-Control "public, max-age=2592000, immutable"
}

View File

@@ -1,11 +1,38 @@
FROM nginx:latest
FROM node:24.13.1 AS build
COPY public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
COPY docker-entrypoint.sh /docker-entrypoint.d/05-docker-entrypoint.sh
# Set the working directory for the build stage
WORKDIR /app
RUN chmod +x /docker-entrypoint.d/05-docker-entrypoint.sh
# Install dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
EXPOSE 5000
# Copy source files that the build depends on
COPY index.html .
COPY public/ public/
COPY src/ src/
COPY tsconfig.json vite.config.ts ./
ARG SEASONED_API=http://localhost:31459
ENV VITE_SEASONED_API=$SEASONED_API
ARG ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ENV VITE_ELASTIC_URL=$ELASTIC_URL
ARG ELASTIC_API_KEY=
ENV VITE_ELASTIC_API_KEY=$ELASTIC_API_KEY
RUN yarn build
FROM caddy:2.11-alpine
COPY Caddyfile /etc/caddy/Caddyfile
# Copy static files
COPY public /usr/share/caddy
# Copy the static build from the previous stage
COPY --from=build /app/dist /usr/share/caddy
EXPOSE 8080
LABEL org.opencontainers.image.source https://github.com/kevinmidboe/seasoned

View File

@@ -1,9 +0,0 @@
#!/bin/sh
set -eu
export SEASONED_API=${SEASONED_API:-http://localhost:31459}
export SEASONED_DOMAIN=${SEASONED_DOMAIN:-localhost}
envsubst '$SEASONED_API,$SEASONED_DOMAIN' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
exec "$@"

66
eslint.config.mjs Normal file
View File

@@ -0,0 +1,66 @@
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import { configs, plugins } from "eslint-config-airbnb-extended";
import { rules as prettierConfigRules } from "eslint-config-prettier";
import prettierPlugin from "eslint-plugin-prettier";
const CUSTOM_RULES = {
"vue/no-v-model-argument": "off",
"no-underscore-dangle": "off",
"vue/multi-word-component-names": "off"
};
const gitignorePath = path.resolve(".", ".gitignore");
// ESLint recommended config
const jsConfig = defineConfig([
{
name: "js/config",
...js.configs.recommended
},
plugins.stylistic,
plugins.importX,
...configs.base.recommended // Airbnb base recommended config
]);
// Node & Airbnb recommended config
const nodeConfig = defineConfig([plugins.node, ...configs.node.recommended]);
// Typescript & Airbnb base TS config
const typescriptConfig = defineConfig([
plugins.typescriptEslint,
...configs.base.typescript
// rules.typescript.typescriptEslintStrict
]);
// Prettier config
const prettierConfig = defineConfig([
{
name: "prettier/plugin/config",
plugins: {
prettier: prettierPlugin
}
},
{
name: "prettier/config",
rules: {
...prettierConfigRules,
"prettier/prettier": "error"
}
}
]);
export default defineConfig([
// Ignore files and folders listed in .gitignore
includeIgnoreFile(gitignorePath),
...jsConfig,
...nodeConfig,
...typescriptConfig,
...prettierConfig,
{
rules: CUSTOM_RULES
}
]);

View File

@@ -1,8 +1,11 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, viewport-fit=cover, initial-scale=1"
/>
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&amp;subset=cyrillic"
@@ -24,7 +27,9 @@
<meta name="theme-color" content="#081c24" />
</head>
<body>
<div id="entry"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<script

View File

@@ -1,30 +0,0 @@
server {
listen 5000 default_server;
listen [::]:5000 default_server;
server_name $SEASONED_DOMAIN;
root /usr/share/nginx/html;
gzip on;
gzip_types application/javascript;
gzip_min_length 1000;
gzip_static on;
location /favicons {
autoindex on;
}
location /dist {
add_header Content-Type application/javascript;
try_files $uri =404;
}
location /api {
proxy_pass $SEASONED_API;
}
location / {
try_files $uri $uri/ /index.html;
index index.html;
}
}

View File

@@ -5,57 +5,30 @@
"author": "Kevin Midboe",
"private": true,
"scripts": {
"dev": "NODE_ENV=development webpack server",
"build": "yarn build:ts && yarn build:webpack",
"build:ts": "tsc --project tsconfig.json",
"build:webpack": "NODE_ENV=production webpack-cli build --progress",
"postbuild": "cp public/dist/index.html public/index.html",
"clean": "rm -r public/dist 2> /dev/null; rm public/index.html 2> /dev/null; rm -r lib 2> /dev/null",
"start": "echo 'Start using docker, consult README'",
"lint": "eslint src --ext .ts,.vue",
"dev": "NODE_ENV=development vite",
"build": "yarn vite build",
"lint": "eslint src; prettier -c src",
"clean": "rm -rf dist/ yarn-*.log 2>/dev/null",
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
},
"dependencies": {
"chart.js": "3.9.1",
"connect-history-api-fallback": "2.0.0",
"dotenv": "^16.0.1",
"express": "4.18.1",
"vue": "3.2.37",
"vue-router": "4.1.3",
"vuex": "4.0.2"
"vue": "3.5.28",
"vue-router": "5.0.3",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "7.18.10",
"@babel/plugin-transform-runtime": "7.18.10",
"@babel/preset-env": "7.18.10",
"@babel/runtime": "7.18.9",
"@types/express": "4.17.13",
"@types/node": "18.6.1",
"@typescript-eslint/eslint-plugin": "5.33.0",
"@typescript-eslint/parser": "5.33.0",
"@vue/cli": "5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-airbnb": "6.0.0",
"babel-loader": "8.2.5",
"css-loader": "6.7.1",
"documentation": "13.2.5",
"eslint": "8.21.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-vue": "9.3.0",
"eslint-plugin-vuejs-accessibility": "1.2.0",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.5.0",
"prettier": "2.7.1",
"@eslint/compat": "^2.0.2",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^10.0.1",
"eslint-config-airbnb-extended": "^3.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "^3.8.1",
"sass": "1.54.3",
"sass-loader": "13.0.2",
"terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1",
"typescript": "4.7.4",
"vue-loader": "17.0.0",
"webpack": "5.74.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.9.3"
"typescript": "5.9.3",
"vite": "^6.0.3"
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div id="app">
<div id="content">
<!-- Header and hamburger navigation -->
<NavigationHeader class="header" />
@@ -14,6 +14,9 @@
<popup />
<darkmode-toggle />
<!-- Command Palette -->
<command-palette />
</div>
</template>
@@ -23,18 +26,19 @@
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
import CommandPalette from "@/components/ui/CommandPalette.vue";
const router = useRouter();
</script>
<style lang="scss">
@import "src/scss/main";
@import "src/scss/media-queries";
@import "scss/main";
@import "scss/media-queries";
#app {
#content {
display: grid;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 1fr;
grid-template-columns: var(--header-size) 100%;
@include mobile {
grid-template-columns: 1fr;
@@ -59,11 +63,12 @@
.content {
display: grid;
grid-column: 2 / 3;
width: calc(100% - var(--header-size));
grid-row: 2;
z-index: 5;
@include mobile {
grid-column: 1 / 3;
width: 100%;
}
}
}

View File

@@ -1,29 +1,31 @@
import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList";
/* eslint-disable n/no-unsupported-features/node-builtins */
import {
IList,
IMediaCredits,
IPersonCredits,
MediaTypes
} from "./interfaces/IList";
import type {
IRequestStatusResponse,
IRequestSubmitResponse
} from "./interfaces/IRequestResponse";
const { ELASTIC, ELASTIC_INDEX, ELASTIC_APIKEY } = process.env;
const API_HOSTNAME = window.location.origin;
const API_HOSTNAME = import.meta.env.VITE_SEASONED_API;
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// - - - TMDB - - -
interface GetMediaOpts {
checkExistance: boolean;
credits: boolean;
releaseDates?: boolean;
}
/**
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @returns {object} Tmdb response
*/
const getMovie = (
id,
{
checkExistance,
credits,
releaseDates
}: { checkExistance: boolean; credits: boolean; releaseDates?: boolean }
) => {
const getMovie = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
@@ -42,22 +44,12 @@ const getMovie = (
});
};
/**
* Fetches tmdb show by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (
id,
{
checkExistance,
credits,
releaseDates
}: { checkExistance: boolean; credits: boolean; releaseDates?: boolean }
) => {
// Fetches tmdb show by id. Can optionally include cast credits in result object.
const getShow = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
@@ -76,13 +68,8 @@ const getShow = (
});
};
/**
* Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getPerson = (id, credits = false) => {
// Fetches tmdb person by id. Can optionally include cast credits in result object.
const getPerson = async (id: number, credits = false) => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) {
@@ -97,12 +84,8 @@ const getPerson = (id, credits = false) => {
});
};
/**
* Fetches tmdb movie credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getMovieCredits = (id: number): Promise<IMediaCredits> => {
// Fetches tmdb movie credits by id.
const getMovieCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -114,12 +97,8 @@ const getMovieCredits = (id: number): Promise<IMediaCredits> => {
});
};
/**
* Fetches tmdb show credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getShowCredits = (id: number): Promise<IMediaCredits> => {
// Fetches tmdb show credits by id.
const getShowCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -131,12 +110,8 @@ const getShowCredits = (id: number): Promise<IMediaCredits> => {
});
};
/**
* Fetches tmdb person credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getPersonCredits = (id: number): Promise<IPersonCredits> => {
// Fetches tmdb person credits by id.
const getPersonCredits = async (id: number): Promise<IPersonCredits> => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
@@ -148,13 +123,11 @@ const getPersonCredits = (id: number): Promise<IPersonCredits> => {
});
};
/**
* Fetches tmdb list by name.
* @param {string} name List the fetch
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => {
// Fetches tmdb list by name.
const getTmdbMovieListByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME);
url.searchParams.append("page", page.toString());
@@ -162,12 +135,8 @@ const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => {
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
/**
* Fetches requested items.
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page = 1) => {
// Fetches requested items.
const getRequests = async (page = 1) => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.searchParams.append("page", page.toString());
@@ -175,20 +144,25 @@ const getRequests = (page = 1) => {
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
const getUserRequests = (page = 1) => {
const getUserRequests = async (page = 1) => {
const url = new URL("/api/v1/user/requests", API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => resp.json());
};
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
// Fetches tmdb movies and shows by query.
const searchTmdb = async (
query: string,
page = 1,
adult = false,
mediaType = null
) => {
const url = new URL("/api/v2/search", API_HOSTNAME);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`;
@@ -208,17 +182,15 @@ const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
// - - - Torrents - - -
/**
* Search for torrents by query
* @param {string} query
* @param {boolean} credits Include credits
* @returns {object} Torrent response
*/
const searchTorrents = query => {
// Search for torrents by query
const searchTorrents = async (query: string) => {
const url = new URL("/api/v1/pirate/search", API_HOSTNAME);
url.searchParams.append("query", query);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href)
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console
@@ -226,19 +198,18 @@ const searchTorrents = query => {
});
};
/**
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdbId
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet: string, name: string, tmdbId: number | null) => {
// Add magnet to download queue.
const addMagnet = async (
magnet: string,
name: string,
tmdbId: number | null
) => {
const url = new URL("/api/v1/pirate/add", API_HOSTNAME);
const options = {
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
magnet,
name,
@@ -256,14 +227,11 @@ const addMagnet = (magnet: string, name: string, tmdbId: number | null) => {
// - - - Plex/Request - - -
/**
* Request a movie or show from id. If authorization token is included the user will be linked
* to the requested item.
* @param {number} id Movie or show id
* @param {string} type Movie or show type
* @returns {object} Success/Failure response
*/
const request = (id, type): Promise<IRequestSubmitResponse> => {
// Request a movie or show from id. If authorization token is included the user will be linked
const request = async (
id: number,
type: MediaTypes.Movie | MediaTypes.Show
): Promise<IRequestSubmitResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
const options = {
@@ -280,13 +248,11 @@ const request = (id, type): Promise<IRequestSubmitResponse> => {
});
};
/**
* Check request status by tmdb id and type
* @param {number} tmdb id
* @param {string} type
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type = null): Promise<IRequestStatusResponse> => {
// Check request status by tmdb id and type
const getRequestStatus = async (
id: number,
type = null
): Promise<IRequestStatusResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
url.searchParams.append("type", type);
@@ -296,29 +262,37 @@ const getRequestStatus = (id, type = null): Promise<IRequestStatusResponse> => {
.catch(err => Promise.reject(err));
};
const watchLink = (title, year) => {
const watchLink = async (title: string, year: string) => {
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title);
url.searchParams.append("year", year);
return fetch(url.href)
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.then(response => response.link);
};
/*
const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
return fetch(url.href).then(resp => resp.json());
};
*/
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const register = async (username: string, password: string) => {
const url = new URL("/api/v1/user", API_HOSTNAME);
const options = {
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
@@ -334,40 +308,49 @@ const register = (username, password) => {
});
};
const login = (username, password, throwError = false) => {
const login = async (
username: string,
password: string,
throwError = false
) => {
const url = new URL("/api/v1/user/login", API_HOSTNAME);
const options = {
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) throw resp;
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const logout = (throwError = false) => {
const logout = async (throwError = false) => {
const url = new URL("/api/v1/user/logout", API_HOSTNAME);
const options = { method: "POST" };
const options: RequestInit = { method: "POST", credentials: "include" };
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) throw resp;
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const getSettings = () => {
const getSettings = async () => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href)
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting user settings"); // eslint-disable-line no-console
@@ -375,12 +358,13 @@ const getSettings = () => {
});
};
const updateSettings = settings => {
const updateSettings = async (settings: any) => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options = {
const options: RequestInit = {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings)
};
@@ -394,30 +378,31 @@ const updateSettings = settings => {
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const linkPlexAccount = async (authToken: string) => {
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
const body = { username, password };
const body = { authToken };
const options = {
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error linking plex account: ${username}`); // eslint-disable-line no-console
console.error("api error linking plex account"); // eslint-disable-line no-console
throw error;
});
};
const unlinkPlexAccount = () => {
const unlinkPlexAccount = async () => {
const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME);
const options = {
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" }
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
@@ -439,7 +424,12 @@ const fetchGraphData = async (
url.searchParams.append("days", String(days));
url.searchParams.append("y_axis", chartType);
return fetch(url.href).then(resp => {
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => {
if (!resp.ok) {
console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console
throw Error(resp.statusText);
@@ -465,15 +455,31 @@ const getEmoji = async () => {
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
interface TimeoutRequestInit extends RequestInit {
timeout: number;
}
async function fetchWithTimeout(url: string, options: TimeoutRequestInit) {
const { timeout = 2000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timer);
return response;
}
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query: string, count = 22) => {
const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC);
const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
const url = new URL(`${ELASTIC_URL}/_search`);
const body = {
sort: [{ popularity: { order: "desc" } }, "_score"],
@@ -521,13 +527,14 @@ const elasticSearchMoviesAndShows = (query: string, count = 22) => {
const options = {
method: "POST",
headers: {
Authorization: `ApiKey ${ELASTIC_APIKEY}`,
"Content-Type": "application/json"
"Content-Type": "application/json",
Authorization: `ApiKey ${ELASTIC_API_KEY}`
},
body: JSON.stringify(body)
body: JSON.stringify(body),
timeout: 1000
};
return fetch(url.href, options)
return fetchWithTimeout(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console
@@ -549,8 +556,6 @@ export {
searchTorrents,
addMagnet,
request,
watchLink,
movieImages,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
@@ -560,6 +565,7 @@ export {
getSettings,
updateSettings,
fetchGraphData,
watchLink,
getEmoji,
elasticSearchMoviesAndShows
};

View File

@@ -11,7 +11,8 @@
</template>
<script setup lang="ts">
import CastListItem from "src/components/CastListItem.vue";
import { defineProps } from "vue";
import CastListItem from "@/components/CastListItem.vue";
import type {
IMovie,
IShow,

View File

@@ -1,9 +1,11 @@
<template>
<canvas ref="graphCanvas"></canvas>
<div class="graph-wrapper">
<canvas ref="graphCanvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
import {
Chart,
LineElement,
@@ -16,12 +18,14 @@
Legend,
Title,
Tooltip,
Filler,
ChartType
} from "chart.js";
import type { BarOptions, ChartOptions } from "chart.js";
import type { Ref } from "vue";
import { convertSecondsToHumanReadable } from "../utils";
import { GraphValueTypes } from "../interfaces/IGraph";
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
Chart.register(
@@ -34,7 +38,8 @@
CategoryScale,
Legend,
Title,
Tooltip
Tooltip,
Filler
);
interface Props {
@@ -42,129 +47,212 @@
data: IGraphData;
type: ChartType;
stacked: boolean;
datasetDescriptionSuffix: string;
tooltipDescriptionSuffix: string;
graphValueType?: GraphValueTypes;
}
Chart.defaults.elements.point.radius = 0;
Chart.defaults.elements.point.hitRadius = 10;
// Chart.defaults.elements.point.pointHoverRadius = 10;
Chart.defaults.elements.point.hoverBorderWidth = 4;
const props = defineProps<Props>();
const graphCanvas: Ref<HTMLCanvasElement> = ref(null);
let graphInstance = null;
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
/* eslint-disable no-use-before-define */
onMounted(() => generateGraph());
watch(() => props.data, generateGraph);
/* eslint-enable no-use-before-define */
/*
|--------------------------------------------------------------------------
| Modern Color System
|--------------------------------------------------------------------------
*/
const graphTemplates = [
{
backgroundColor: "rgba(54, 162, 235, 0.2)",
borderColor: "rgba(54, 162, 235, 1)",
borderWidth: 1,
tension: 0.4
borderColor: "#6366F1",
backgroundColor: "rgba(99,102,241,0.12)"
},
{
backgroundColor: "rgba(255, 159, 64, 0.2)",
borderColor: "rgba(255, 159, 64, 1)",
borderWidth: 1,
tension: 0.4
borderColor: "#F59E0B",
backgroundColor: "rgba(245,158,11,0.12)"
},
{
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
tension: 0.4
borderColor: "#10B981",
backgroundColor: "rgba(16,185,129,0.12)"
}
];
// const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
// "--text-color-5"
// );
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) {
/*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
onBeforeUnmount(() => {
if (graphInstance) graphInstance.destroy();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
}
function hydrateDataset(dataset: IGraphDataset, index: number) {
const base = graphTemplates[index % graphTemplates.length];
if (props.type === "bar") {
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
backgroundColor: base.borderColor,
inflateAmount: 0,
borderRadius: {
topLeft: 8,
topRight: 8,
bottomLeft: 8,
bottomRight: 8
},
borderSkipped: false,
borderWidth: 2,
borderColor: "transparent",
// Slight spacing between categories
barPercentage: 0.8,
categoryPercentage: 0.9
} as BarOptions;
}
// Line chart — subtle, minimal points
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
...graphTemplates[index]
borderColor: base.borderColor,
backgroundColor: base.backgroundColor,
borderWidth: 2,
tension: 0.35,
fill: true,
pointRadius: 2,
pointHoverRadius: 5,
pointHitRadius: 12,
pointBackgroundColor: base.borderColor,
pointBorderColor: base.borderColor,
pointBorderWidth: 0
};
}
function removeEmptyDataset(dataset: IGraphDataset) {
/* eslint-disable-next-line no-unneeded-ternary */
return dataset.data.every(point => point === 0) ? false : true;
}
/*
|--------------------------------------------------------------------------
| Chart Generator
|--------------------------------------------------------------------------
*/
function generateGraph() {
if (!graphCanvas.value) return;
const datasets = props.data.series
.filter(removeEmptyDataset)
.map(hydrateGraphLineOptions);
.map(hydrateDataset);
const graphOptions = {
const chartData = {
labels: props.data.labels,
datasets
};
const options: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
layout: {
padding: { top: 8 }
},
plugins: {
tooltip: {
callbacks: {
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
label: tooltipItem => {
const context = tooltipItem.dataset.label.split(" ")[0];
const text = `${context} ${props.tooltipDescriptionSuffix}`;
legend: {
display: true
},
tooltip: {
backgroundColor: "#111827",
bodyColor: "#e5e7eb",
padding: 12,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: (tooltipItem: any) => {
const context = tooltipItem.dataset.label.split(" ")[0];
let type = GraphTypes.Plays;
let value = tooltipItem.raw;
if (props.graphValueType === GraphValueTypes.Time) {
if (props.graphValueType === String(GraphTypes.Duration)) {
value = convertSecondsToHumanReadable(value);
type = GraphTypes.Duration;
}
return ` ${text}: ${value}`;
const text = `${context} ${type}`;
return `${text}: ${value}`;
}
}
}
},
scales: {
xAxes: {
x: {
stacked: props.stacked,
gridLines: {
display: false
grid: {
display: false,
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 }
}
},
yAxes: {
y: {
stacked: props.stacked,
beginAtZero: true,
grid: {
color: "rgba(0,0,0,0.04)",
drawBorder: false
},
ticks: {
callback: value => {
if (props.graphValueType === GraphValueTypes.Time) {
color: "#9CA3AF",
font: { size: 11 },
padding: 8,
callback: (value: number) => {
if (props.graphValueType === String(GraphTypes.Duration)) {
return convertSecondsToHumanReadable(value);
}
return value;
},
beginAtZero: true
}
}
}
}
};
const chartData = {
labels: props.data.labels.toString().split(","),
datasets
};
if (graphInstance) {
graphInstance.clear();
graphInstance.data = chartData;
graphInstance.update("none");
graphInstance.update();
return;
}
graphInstance = new Chart(graphCanvas.value, {
type: props.type,
data: chartData,
options: graphOptions
options
});
}
</script>
<style lang="scss" scoped></style>
<style scoped lang="scss">
.graph-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 240px;
}
</style>

View File

@@ -100,8 +100,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
header {
width: 100%;

View File

@@ -47,9 +47,9 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
header {
width: 100%;

View File

@@ -1,16 +1,26 @@
<template>
<div v-if="isOpen" class="movie-popup" @click="close" @keydown.enter="close">
<div
v-if="isOpen"
ref="popupContainer"
class="movie-popup"
role="dialog"
aria-modal="true"
tabindex="-1"
@click="close"
@keydown.enter="close"
@keydown="handleKeydown"
>
<div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="close"></button>
<button class="movie-popup__close" @click="close" tabindex="0"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import { useStore } from "vuex";
import Movie from "@/components/popup/Movie.vue";
import Person from "@/components/popup/Person.vue";
@@ -26,6 +36,8 @@
const isOpen: Ref<boolean> = ref();
const id: Ref<string> = ref();
const type: Ref<MediaTypes> = ref();
const popupContainer = ref<HTMLElement | null>(null);
let previouslyFocusedElement: HTMLElement | null = null;
const unsubscribe = store.subscribe((mutation, state) => {
if (!mutation.type.includes("popup")) return;
@@ -46,7 +58,7 @@
let _type: MediaTypes;
const params = new URLSearchParams(window.location.search);
params.forEach((value, key) => {
params.forEach((_, key) => {
if (
key !== MediaTypes.Movie &&
key !== MediaTypes.Show &&
@@ -76,6 +88,75 @@
close();
}
function getFocusableElements(): HTMLElement[] {
if (!popupContainer.value) return [];
const focusableSelectors = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(
popupContainer.value.querySelectorAll(focusableSelectors)
) as HTMLElement[];
}
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
function handleKeydown(event: KeyboardEvent) {
trapFocus(event);
}
function setInitialFocus() {
nextTick(() => {
// Focus the popup container itself instead of a specific element
// This allows tab to start fresh without any element being focused
if (popupContainer.value) {
popupContainer.value.focus();
}
});
}
watch(isOpen, newValue => {
if (newValue) {
// Store the previously focused element
previouslyFocusedElement = document.activeElement as HTMLElement;
// Set focus to popup
setInitialFocus();
} else {
// Restore focus to previously focused element
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
}
});
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
@@ -90,8 +171,8 @@
</script>
<style lang="scss">
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.movie-popup {
position: fixed;
@@ -104,6 +185,10 @@
-webkit-overflow-scrolling: touch;
overflow: auto;
&:focus {
outline: none;
}
&__box {
max-width: 768px;
position: relative;
@@ -136,7 +221,7 @@
left: 10px;
width: 20px;
height: 2px;
background: $white;
background-color: white;
}
&:before {
transform: rotate(45deg);
@@ -145,7 +230,7 @@
transform: rotate(-45deg);
}
&:hover {
background: $green;
background-color: var(--highlight-color);
}
}
}

View File

@@ -34,8 +34,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/media-queries";
@import "scss/main";
.no-results {
width: 100%;

View File

@@ -22,7 +22,11 @@
</div>
</figure>
<div class="movie-item__info">
<div
class="movie-item__info"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<p v-if="listItem.title || listItem.name" class="movie-item__title">
{{ listItem.title || listItem.name }}
</p>
@@ -111,9 +115,9 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.movie-item {
padding: 15px;

View File

@@ -172,7 +172,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.resultSection {
background-color: var(--background-color);

View File

@@ -0,0 +1,503 @@
<template>
<div class="admin-stats">
<div class="admin-stats__header">
<h2 class="admin-stats__title">Statistics</h2>
<div class="admin-stats__controls">
<select
v-model="timeRange"
class="time-range-select"
@change="fetchStats"
>
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="all">All Time</option>
</select>
<button class="refresh-btn" @click="fetchStats" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="admin-stats__loading">Loading statistics...</div>
<div v-else class="admin-stats__grid">
<div
class="stat-card"
v-for="stat in statCards"
:key="stat.key"
@click="handleCardClick(stat.key)"
:class="{ 'stat-card--clickable': stat.clickable }"
>
<div class="stat-card__header">
<component :is="stat.icon" class="stat-card__icon" />
<span
v-if="stat.trend !== 0"
:class="[
'stat-card__trend',
stat.trend > 0 ? 'stat-card__trend--up' : 'stat-card__trend--down'
]"
>
{{ stat.trend > 0 ? "↑" : "↓" }} {{ Math.abs(stat.trend) }}%
</span>
</div>
<span class="stat-card__value">{{ stat.value }}</span>
<span class="stat-card__label">{{ stat.label }}</span>
<div v-if="stat.sparkline" class="stat-card__sparkline">
<div
v-for="(point, index) in stat.sparkline"
:key="index"
class="sparkline-bar"
:style="{
height: `${(point / Math.max(...stat.sparkline)) * 100}%`
}"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Stat {
key: string;
value: string | number;
label: string;
trend: number;
icon: any;
clickable: boolean;
sparkline?: number[];
}
const stats = ref({
totalUsers: 0,
activeTorrents: 0,
totalRequests: 0,
pendingRequests: 0,
approvedRequests: 0,
totalStorage: "0 GB",
usersTrend: 0,
torrentsTrend: 0,
requestsTrend: 0,
pendingTrend: 0,
approvedTrend: 0,
storageTrend: 0,
usersSparkline: [] as number[],
torrentsSparkline: [] as number[],
requestsSparkline: [] as number[]
});
const loading = ref(false);
const timeRange = ref("week");
const statCards = computed<Stat[]>(() => [
{
key: "totalUsers",
value: stats.value.totalUsers,
label: "Total Users",
trend: stats.value.usersTrend,
icon: IconProfile,
clickable: true,
sparkline: stats.value.usersSparkline
},
{
key: "activeTorrents",
value: stats.value.activeTorrents,
label: "Active Torrents",
trend: stats.value.torrentsTrend,
icon: IconPlay,
clickable: true,
sparkline: stats.value.torrentsSparkline
},
{
key: "totalRequests",
value: stats.value.totalRequests,
label: "Total Requests",
trend: stats.value.requestsTrend,
icon: IconRequest,
clickable: true,
sparkline: stats.value.requestsSparkline
},
{
key: "pendingRequests",
value: stats.value.pendingRequests,
label: "Pending Requests",
trend: stats.value.pendingTrend,
icon: IconRequest,
clickable: true
},
{
key: "approvedRequests",
value: stats.value.approvedRequests,
label: "Approved",
trend: stats.value.approvedTrend,
icon: IconRequest,
clickable: true
},
{
key: "totalStorage",
value: stats.value.totalStorage,
label: "Storage Used",
trend: stats.value.storageTrend,
icon: IconActivity,
clickable: false
}
]);
const generateSparkline = (
baseValue: number,
points: number = 7
): number[] => {
return Array.from({ length: points }, () => {
const variance = Math.random() * 0.3 - 0.15;
return Math.max(0, Math.floor(baseValue * (1 + variance)));
});
};
async function fetchStats() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const baseUsers = 142;
const baseTorrents = 23;
const baseRequests = 856;
stats.value = {
totalUsers: baseUsers,
activeTorrents: baseTorrents,
totalRequests: baseRequests,
pendingRequests: 12,
approvedRequests: 712,
totalStorage: "2.4 TB",
usersTrend: 8.5,
torrentsTrend: -3.2,
requestsTrend: 12.7,
pendingTrend: -15.4,
approvedTrend: 18.2,
storageTrend: 5.8,
usersSparkline: generateSparkline(baseUsers / 7),
torrentsSparkline: generateSparkline(baseTorrents),
requestsSparkline: generateSparkline(baseRequests / 30)
};
} finally {
loading.value = false;
}
}
function handleCardClick(key: string) {
console.log(`Stat card clicked: ${key}`);
}
onMounted(() => fetchStats());
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.admin-stats {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
@include mobile-only {
margin-bottom: 0.6rem;
width: 100%;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
justify-content: space-between;
}
}
&__loading {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
width: 100%;
}
}
}
.time-range-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.stat-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
text-align: center;
transition: all 0.2s;
overflow: hidden;
min-width: 0;
@include mobile-only {
padding: 0.6rem 0.4rem;
width: 100%;
box-sizing: border-box;
}
&--clickable {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: var(--background-40);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
transform: scale(0.98);
}
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 0.4rem;
@include mobile-only {
margin-bottom: 0.3rem;
}
}
&__icon {
width: 20px;
height: 20px;
fill: var(--highlight-color);
opacity: 0.8;
@include mobile-only {
width: 16px;
height: 16px;
}
}
&__trend {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
@include mobile-only {
font-size: 0.65rem;
padding: 0.15rem 0.3rem;
}
&--up {
color: $white;
background-color: var(--color-success-highlight);
}
&--down {
color: $white;
background-color: var(--color-error-highlight);
}
}
&__value {
font-size: 2.2rem;
font-weight: 600;
color: var(--highlight-color);
margin-bottom: 0.15rem;
line-height: 1.1;
padding: 1rem 0;
@include mobile-only {
margin-bottom: 0.1rem;
}
}
&__label {
font-size: 0.8rem;
color: $text-color-70;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 0.4rem;
word-break: break-word;
max-width: 100%;
line-height: 1.2;
@include mobile-only {
margin-bottom: 0.3rem;
letter-spacing: 0.2px;
}
}
&__sparkline {
display: flex;
align-items: flex-end;
justify-content: space-between;
width: 100%;
height: 24px;
margin-top: 0.4rem;
gap: 2px;
@include mobile-only {
height: 18px;
margin-top: 0.3rem;
gap: 1px;
}
}
}
.sparkline-bar {
flex: 1;
background: linear-gradient(
180deg,
var(--highlight-color) 0%,
var(--color-green-70) 100%
);
border-radius: 2px 2px 0 0;
min-height: 3px;
transition: all 0.3s ease;
.stat-card:hover & {
opacity: 0.9;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,685 @@
<template>
<div class="activity-feed">
<div class="activity-feed__header">
<h2 class="activity-feed__title">Recent Activity</h2>
<div class="activity-feed__controls">
<select v-model="typeFilter" class="activity-feed__filter">
<option value="">All Types</option>
<option value="request">Requests</option>
<option value="download">Downloads</option>
<option value="user">Users</option>
<option value="movie">Library</option>
</select>
<select
v-model="timeFilter"
class="activity-feed__filter"
@change="fetchActivities"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<button
class="refresh-btn"
@click="fetchActivities"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="activity-feed__loading">
Loading activities...
</div>
<div v-else-if="error" class="activity-feed__error">{{ error }}</div>
<div v-else class="activity-feed__list">
<div
class="activity-item"
v-for="activity in filteredActivities"
:key="activity.id"
@click="handleActivityClick(activity)"
>
<div
:class="[
'activity-item__icon',
`activity-item__icon--${activity.type}`
]"
>
<component :is="getIcon(activity.type)" />
</div>
<div class="activity-item__content">
<div class="activity-item__header">
<span class="activity-item__message">{{ activity.message }}</span>
<span v-if="activity.metadata" class="activity-item__badge">
{{ activity.metadata }}
</span>
</div>
<div class="activity-item__footer">
<span class="activity-item__user" v-if="activity.user">{{
activity.user
}}</span>
<span class="activity-item__time">{{
formatTime(activity.timestamp)
}}</span>
</div>
</div>
</div>
<div v-if="filteredActivities.length === 0" class="activity-feed__empty">
No activities found
</div>
</div>
<div
v-if="!loading && filteredActivities.length > 0"
class="activity-feed__footer"
>
<span class="activity-count"
>{{ filteredActivities.length }} of
{{ activities.length }} activities</span
>
<button
v-if="hasMore"
class="load-more-btn"
@click="loadMore"
:disabled="loadingMore"
>
{{ loadingMore ? "Loading..." : "Load More" }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconActivity from "@/icons/IconActivity.vue";
type ActivityType = "request" | "download" | "user" | "movie";
interface Activity {
id: number;
type: ActivityType;
message: string;
timestamp: Date;
user?: string;
metadata?: string;
details?: any;
}
const activities = ref<Activity[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref("");
const typeFilter = ref<ActivityType | "">("");
const timeFilter = ref("24h");
const hasMore = ref(true);
const page = ref(1);
const filteredActivities = computed(() => {
let result = [...activities.value];
if (typeFilter.value) {
result = result.filter(a => a.type === typeFilter.value);
}
return result;
});
const getIcon = (type: string) => {
const icons: Record<string, any> = {
request: IconRequest,
download: IconPlay,
user: IconProfile,
movie: IconMovie
};
return icons[type] || IconMovie;
};
const formatTime = (date: Date): string => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const generateMockActivities = (
count: number,
startId: number
): Activity[] => {
const types: ActivityType[] = ["request", "download", "user", "movie"];
const messages = {
request: [
"New request: Interstellar (2014)",
"Request approved: Oppenheimer",
"Request denied: The Matrix",
"Request fulfilled: Dune Part Two"
],
download: [
"Torrent completed: Dune Part Two",
"Torrent started: Poor Things",
"Download failed: Network Error",
"Torrent paused by admin"
],
user: [
"New user registered: john_doe",
"User upgraded to VIP: sarah_s",
"User login from new device: alex_p",
"Password changed: mike_r"
],
movie: [
"Movie added to library: The Batman",
"Library scan completed: 12 new items",
"Show updated: Breaking Bad S5",
"Media deleted: Old Movie (1999)"
]
};
const users = [
"admin",
"kevin_m",
"sarah_s",
"john_doe",
"alex_p",
"mike_r"
];
return Array.from({ length: count }, (_, i) => {
const type = types[Math.floor(Math.random() * types.length)];
const typeMessages = messages[type];
const message =
typeMessages[Math.floor(Math.random() * typeMessages.length)];
const timeOffset = Math.random() * 24 * 60 * 60 * 1000; // Random time in last 24h
return {
id: startId + i,
type,
message,
timestamp: new Date(Date.now() - timeOffset),
user: users[Math.floor(Math.random() * users.length)],
metadata: type === "request" ? "Pending" : undefined
};
});
};
async function fetchActivities() {
loading.value = true;
error.value = "";
page.value = 1;
try {
await new Promise(resolve => setTimeout(resolve, 500));
activities.value = generateMockActivities(15, 1).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
hasMore.value = true;
} catch (e) {
error.value = "Failed to load activities";
} finally {
loading.value = false;
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const newActivities = generateMockActivities(
10,
activities.value.length + 1
);
activities.value = [...activities.value, ...newActivities].sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
page.value += 1;
if (page.value >= 5) {
hasMore.value = false;
}
} finally {
loadingMore.value = false;
}
}
function handleActivityClick(activity: Activity) {
console.log("Activity clicked:", activity);
}
onMounted(fetchActivities);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.activity-feed {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
gap: 0.6rem;
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
width: 100%;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
min-width: 0;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__error {
color: var(--color-error-highlight);
}
&__list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 450px;
overflow-y: auto;
padding-right: 0.25rem;
@include mobile-only {
max-height: 350px;
gap: 0.35rem;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--background-40);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-color-50);
border-radius: 3px;
&:hover {
background: var(--text-color-70);
}
}
}
&__empty {
padding: 2rem;
text-align: center;
color: $text-color-50;
font-style: italic;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
display: flex;
justify-content: space-between;
align-items: center;
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
}
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
transition: background-color 0.2s;
cursor: pointer;
min-width: 0;
@include mobile-only {
gap: 0.6rem;
padding: 0.6rem;
width: 100%;
box-sizing: border-box;
}
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
&:hover {
background-color: var(--background-ui);
}
&:active {
background-color: var(--background-40);
}
}
&__icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
@include mobile-only {
width: 26px;
height: 26px;
}
&--request {
background-color: #3b82f6;
}
&--download {
background-color: var(--highlight-color);
}
&--user {
background-color: #8b5cf6;
}
&--movie {
background-color: #f59e0b;
}
svg {
width: 14px;
height: 14px;
fill: $white;
@include mobile-only {
width: 13px;
height: 13px;
}
}
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
@include mobile-only {
gap: 0.2rem;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.4rem;
}
&__message {
font-size: 0.85rem;
color: $text-color;
line-height: 1.3;
flex: 1;
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.78rem;
line-height: 1.25;
}
}
&__badge {
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background-color: var(--color-warning);
color: $black;
font-weight: 500;
text-transform: uppercase;
@include mobile-only {
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
}
}
&__footer {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.75rem;
@include mobile-only {
font-size: 0.7rem;
gap: 0.35rem;
}
}
&__user {
color: $text-color-70;
font-weight: 500;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "@";
opacity: 0.7;
}
}
&__time {
color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "•";
margin-right: 0.5rem;
@include mobile-only {
margin-right: 0.3rem;
}
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.load-more-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--background-40);
background-color: var(--background-ui);
color: $text-color;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
@include mobile-only {
width: 100%;
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
&:hover:not(:disabled) {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.activity-count {
font-size: 0.8rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.75rem;
text-align: center;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,750 @@
<template>
<div class="system-status">
<div class="system-status__header">
<h2 class="system-status__title">System Status</h2>
<button
class="refresh-btn"
@click="fetchSystemStatus"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
<div v-if="loading" class="system-status__loading">
Loading system status...
</div>
<div v-else class="system-status__items">
<div
class="status-item"
v-for="item in systemItems"
:key="item.name"
@click="showDetails(item)"
>
<div class="status-item__header">
<span class="status-item__name">{{ item.name }}</span>
<div class="status-item__indicator-wrapper">
<span class="status-item__uptime" v-if="item.uptime">{{
item.uptime
}}</span>
<span
:class="[
'status-item__indicator',
`status-item__indicator--${item.status}`
]"
:title="`${item.status}`"
></span>
</div>
</div>
<div class="status-item__details">
<span class="status-item__value">{{ item.value }}</span>
<span class="status-item__description">{{ item.description }}</span>
</div>
<div v-if="item.metrics" class="status-item__metrics">
<div
v-for="metric in item.metrics"
:key="metric.label"
class="metric"
>
<span class="metric__label">{{ metric.label }}</span>
<div class="metric__bar">
<div
class="metric__fill"
:style="{ width: `${metric.value}%` }"
:class="getMetricClass(metric.value)"
></div>
</div>
<span class="metric__value">{{ metric.value }}%</span>
</div>
</div>
</div>
</div>
<!-- Details Modal -->
<div v-if="selectedItem" class="modal-overlay" @click="closeDetails">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ selectedItem.name }} Details</h3>
<button class="close-btn" @click="closeDetails">
<IconClose />
</button>
</div>
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Status:</span>
<span
:class="['detail-value', `detail-value--${selectedItem.status}`]"
>
{{ selectedItem.status.toUpperCase() }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Current Value:</span>
<span class="detail-value">{{ selectedItem.value }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Description:</span>
<span class="detail-value">{{ selectedItem.description }}</span>
</div>
<div v-if="selectedItem.uptime" class="detail-row">
<span class="detail-label">Uptime:</span>
<span class="detail-value">{{ selectedItem.uptime }}</span>
</div>
<div v-if="selectedItem.lastCheck" class="detail-row">
<span class="detail-label">Last Check:</span>
<span class="detail-value">{{ selectedItem.lastCheck }}</span>
</div>
<div v-if="selectedItem.logs" class="detail-logs">
<h4>Recent Logs</h4>
<div
class="log-entry"
v-for="(log, index) in selectedItem.logs"
:key="index"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn" @click="restartService(selectedItem)">
Restart Service
</button>
<button
class="action-btn action-btn--secondary"
@click="viewFullLogs(selectedItem)"
>
View Full Logs
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
interface Metric {
label: string;
value: number;
}
interface LogEntry {
time: string;
message: string;
}
interface SystemItem {
name: string;
status: "online" | "warning" | "offline";
value: string;
description: string;
uptime?: string;
lastCheck?: string;
metrics?: Metric[];
logs?: LogEntry[];
}
const systemItems = ref<SystemItem[]>([]);
const loading = ref(false);
const selectedItem = ref<SystemItem | null>(null);
const getMetricClass = (value: number) => {
if (value >= 90) return "metric__fill--critical";
if (value >= 70) return "metric__fill--warning";
return "metric__fill--good";
};
async function fetchSystemStatus() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
systemItems.value = [
{
name: "API Server",
status: "online",
value: "Running",
description: "All endpoints responding",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "CPU", value: 23 },
{ label: "Memory", value: 45 }
],
logs: [
{ time: "2m ago", message: "Health check passed" },
{ time: "5m ago", message: "Request handled: /api/v2/movie" },
{ time: "7m ago", message: "Cache hit: user_settings" }
]
},
{
name: "Disk Space",
status: "warning",
value: "45% Used",
description: "1.2 TB / 2.7 TB",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "System", value: 45 },
{ label: "Media", value: 78 }
],
logs: [
{ time: "5m ago", message: "Disk usage check completed" },
{ time: "10m ago", message: "Media folder: 78% full" }
]
},
{
name: "Plex Connection",
status: "online",
value: "Connected",
description: "Server: Home",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [{ label: "Response Time", value: 15 }],
logs: [
{ time: "2m ago", message: "Plex API request successful" },
{ time: "8m ago", message: "Library sync completed" }
]
}
];
} finally {
loading.value = false;
}
}
function showDetails(item: SystemItem) {
selectedItem.value = item;
}
function closeDetails() {
selectedItem.value = null;
}
function restartService(item: SystemItem) {
console.log(`Restarting service: ${item.name}`);
alert(`Restart initiated for ${item.name}`);
closeDetails();
}
function viewFullLogs(item: SystemItem) {
console.log(`Viewing full logs for: ${item.name}`);
alert(`Full logs for ${item.name} would open here`);
}
onMounted(fetchSystemStatus);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.system-status {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
@include mobile-only {
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
}
}
&__loading {
padding: 1.5rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1rem;
font-size: 0.85rem;
}
}
&__items {
display: flex;
flex-direction: column;
gap: 0.6rem;
@include mobile-only {
gap: 0.5rem;
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.status-item {
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
min-width: 0;
@include mobile-only {
padding: 0.6rem;
width: 100%;
box-sizing: border-box;
}
&:hover {
background-color: var(--background-40);
transform: translateX(2px);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
background-color: var(--background-40);
transform: scale(0.98);
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.2rem;
}
&__name {
font-weight: 500;
color: $text-color;
font-size: 0.9rem;
line-height: 1.2;
@include mobile-only {
font-size: 0.82rem;
}
}
&__indicator-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
gap: 0.35rem;
}
}
&__uptime {
font-size: 0.75rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
}
&__indicator {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
@include mobile-only {
width: 8px;
height: 8px;
}
&--online {
background-color: var(--color-success-highlight);
box-shadow: 0 0 6px var(--color-success);
}
&--warning {
background-color: var(--color-warning-highlight);
box-shadow: 0 0 6px var(--color-warning);
}
&--offline {
background-color: var(--color-error-highlight);
box-shadow: 0 0 6px var(--color-error);
}
}
&__details {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
margin-bottom: 0.3rem;
}
}
&__value {
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.2;
@include mobile-only {
font-size: 0.75rem;
}
}
&__description {
font-size: 0.75rem;
color: $text-color-50;
line-height: 1.2;
@include mobile-only {
font-size: 0.7rem;
}
}
&__metrics {
margin-top: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
@include mobile-only {
margin-top: 0.3rem;
gap: 0.3rem;
}
}
}
.metric {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
&__label {
min-width: 65px;
color: $text-color-70;
line-height: 1;
}
&__bar {
flex: 1;
height: 5px;
background-color: var(--background-40);
border-radius: 3px;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
&--good {
background-color: var(--color-success-highlight);
}
&--warning {
background-color: var(--color-warning-highlight);
}
&--critical {
background-color: var(--color-error-highlight);
}
}
&__value {
min-width: 35px;
text-align: right;
color: $text-color-50;
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
@include mobile-only {
padding: 0.5rem;
align-items: flex-end;
}
}
.modal-content {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
@include mobile-only {
max-height: 90vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 1rem;
}
h3 {
margin: 0;
color: $text-color;
font-weight: 400;
@include mobile-only {
font-size: 1rem;
}
}
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-40);
}
svg {
width: 20px;
height: 20px;
fill: $text-color;
}
}
.modal-body {
padding: 1.5rem;
@include mobile-only {
padding: 1rem;
}
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--background-40);
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-weight: 500;
color: $text-color-70;
}
.detail-value {
color: $text-color;
&--online {
color: var(--color-success-highlight);
}
&--warning {
color: var(--color-warning-highlight);
}
&--offline {
color: var(--color-error-highlight);
}
}
.detail-logs {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-40);
h4 {
margin: 0 0 0.75rem 0;
color: $text-color;
font-weight: 400;
font-size: 0.95rem;
}
}
.log-entry {
display: flex;
gap: 1rem;
padding: 0.5rem;
font-size: 0.8rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.log-time {
min-width: 60px;
color: $text-color-50;
}
.log-message {
color: $text-color-70;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--background-40);
display: flex;
gap: 0.5rem;
justify-content: flex-end;
@include mobile-only {
padding: 1rem;
flex-direction: column-reverse;
}
}
.action-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--highlight-color);
background-color: var(--highlight-color);
color: $white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
&:hover {
background-color: var(--color-green-90);
border-color: var(--color-green-90);
}
&--secondary {
background-color: transparent;
color: $text-color;
border-color: var(--background-40);
&:hover {
background-color: var(--background-ui);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>

View File

@@ -0,0 +1,723 @@
<template>
<div class="torrent-management">
<div class="torrent-management__header">
<h2 class="torrent-management__title">Torrent Management</h2>
<div class="torrent-management__controls">
<input
v-model="searchQuery"
type="text"
placeholder="Search torrents..."
class="torrent-management__search"
/>
<select v-model="statusFilter" class="torrent-management__filter">
<option value="">All Status</option>
<option value="seeding">Seeding</option>
<option value="downloading">Downloading</option>
<option value="paused">Paused</option>
<option value="stopped">Stopped</option>
</select>
<button class="refresh-btn" @click="fetchTorrents" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="torrent-management__loading">
Loading torrents...
</div>
<div v-else-if="error" class="torrent-management__error">{{ error }}</div>
<table v-else class="torrent-management__table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">
Name
<span v-if="sortColumn === 'name'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile" @click="sortBy('size')" class="sortable">
Size
<span v-if="sortColumn === 'size'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile" @click="sortBy('seeders')" class="sortable">
Seeders
<span v-if="sortColumn === 'seeders'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile">Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in filteredTorrents"
:key="torrent.id"
:class="{ processing: torrent.processing }"
>
<td class="torrent-name" :title="torrent.name">
<div class="torrent-name__title">{{ torrent.name }}</div>
<div v-if="isMobile" class="torrent-name__meta">
<span class="meta-item">{{ torrent.size }}</span>
<span class="meta-separator"></span>
<span class="meta-item">{{ torrent.seeders }} seeders</span>
<span class="meta-separator"></span>
<span
:class="['status-badge', `status-badge--${torrent.status}`]"
>
{{ torrent.status }}
</span>
</div>
</td>
<td v-if="!isMobile">{{ torrent.size }}</td>
<td v-if="!isMobile">{{ torrent.seeders }}</td>
<td v-if="!isMobile">
<span :class="['status-badge', `status-badge--${torrent.status}`]">
{{ torrent.status }}
</span>
</td>
<td class="actions">
<button
v-if="
torrent.status === 'seeding' || torrent.status === 'downloading'
"
class="action-btn"
title="Pause"
@click="pauseTorrent(torrent)"
:disabled="torrent.processing"
>
<IconStop />
</button>
<button
v-if="torrent.status === 'paused' || torrent.status === 'stopped'"
class="action-btn"
title="Resume"
@click="resumeTorrent(torrent)"
:disabled="torrent.processing"
>
<IconPlay />
</button>
<button
class="action-btn action-btn--danger"
title="Delete"
@click="deleteTorrent(torrent)"
:disabled="torrent.processing"
>
<IconClose />
</button>
<button
class="action-btn"
title="Details"
@click="showDetails(torrent)"
>
<IconInfo />
</button>
</td>
</tr>
</tbody>
</table>
<div class="torrent-management__footer">
<span class="torrent-count"
>{{ filteredTorrents.length }} of {{ torrents.length }} torrents</span
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconStop from "@/icons/IconStop.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Torrent {
id: number;
name: string;
size: string;
seeders: number;
leechers: number;
uploaded: string;
downloaded: string;
ratio: number;
status: "seeding" | "downloading" | "paused" | "stopped";
processing?: boolean;
}
const torrents = ref<Torrent[]>([]);
const loading = ref(false);
const error = ref("");
const searchQuery = ref("");
const statusFilter = ref("");
const sortColumn = ref<keyof Torrent>("name");
const sortDirection = ref<"asc" | "desc">("asc");
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
function handleResize() {
windowWidth.value = window.innerWidth;
}
const filteredTorrents = computed(() => {
let result = [...torrents.value];
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(t => t.name.toLowerCase().includes(query));
}
if (statusFilter.value) {
result = result.filter(t => t.status === statusFilter.value);
}
result.sort((a, b) => {
const aVal = a[sortColumn.value];
const bVal = b[sortColumn.value];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection.value === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
if (typeof aVal === "number" && typeof bVal === "number") {
return sortDirection.value === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});
return result;
});
function sortBy(column: keyof Torrent) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
} else {
sortColumn.value = column;
sortDirection.value = "asc";
}
}
async function fetchTorrents() {
loading.value = true;
error.value = "";
try {
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = [
{
id: 1,
name: "Movie.Name.2024.1080p.BluRay.x264",
size: "2.4 GB",
seeders: 156,
leechers: 23,
uploaded: "45.2 GB",
downloaded: "2.4 GB",
ratio: 18.83,
status: "seeding"
},
{
id: 2,
name: "TV.Show.S01E01.720p.WEB-DL",
size: "1.2 GB",
seeders: 89,
leechers: 12,
uploaded: "12.8 GB",
downloaded: "1.2 GB",
ratio: 10.67,
status: "seeding"
},
{
id: 3,
name: "Documentary.2024.HDRip",
size: "890 MB",
seeders: 45,
leechers: 8,
uploaded: "2.1 GB",
downloaded: "650 MB",
ratio: 3.31,
status: "downloading"
},
{
id: 4,
name: "Anime.Series.S02E10.1080p",
size: "1.8 GB",
seeders: 234,
leechers: 56,
uploaded: "89.4 GB",
downloaded: "1.8 GB",
ratio: 49.67,
status: "seeding"
},
{
id: 5,
name: "Concert.2024.4K.UHD",
size: "12.5 GB",
seeders: 67,
leechers: 5,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "paused"
},
{
id: 6,
name: "Drama.Series.2024.S01E05.1080p",
size: "2.1 GB",
seeders: 112,
leechers: 34,
uploaded: "8.9 GB",
downloaded: "2.1 GB",
ratio: 4.24,
status: "seeding"
},
{
id: 7,
name: "Action.Movie.2024.BRRip",
size: "1.5 GB",
seeders: 0,
leechers: 0,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "stopped"
}
];
} catch (e) {
error.value = "Failed to load torrents";
} finally {
loading.value = false;
}
}
async function pauseTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "paused";
torrent.processing = false;
}
async function resumeTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "seeding";
torrent.processing = false;
}
async function deleteTorrent(torrent: Torrent) {
if (!confirm(`Are you sure you want to delete "${torrent.name}"?`)) return;
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = torrents.value.filter(t => t.id !== torrent.id);
}
function showDetails(torrent: Torrent) {
alert(
`Torrent Details:\n\nName: ${torrent.name}\nSize: ${torrent.size}\nSeeders: ${torrent.seeders}\nLeechers: ${torrent.leechers}\nUploaded: ${torrent.uploaded}\nDownloaded: ${torrent.downloaded}\nRatio: ${torrent.ratio.toFixed(2)}\nStatus: ${torrent.status}`
);
}
onMounted(() => {
fetchTorrents();
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.torrent-management {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
overflow-x: auto;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
gap: 0.6rem;
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
width: 100%;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
}
&__search {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
@include mobile-only {
flex: 1;
min-width: 0;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__error {
color: var(--color-error-highlight);
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
}
}
&__table {
width: 100%;
max-width: 100%;
border-spacing: 0;
border-radius: 0.5rem;
overflow: hidden;
table-layout: fixed;
@include mobile-only {
table-layout: auto;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 0.5rem 0.4rem;
font-size: 0.75rem;
}
}
th {
background-color: var(--table-background-color);
color: var(--table-header-text-color);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
font-weight: 400;
@include mobile-only {
font-size: 0.7rem;
letter-spacing: 0.3px;
white-space: nowrap;
}
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--background-80);
}
}
}
td {
font-size: 0.85rem;
color: $text-color;
@include mobile-only {
font-size: 0.75rem;
white-space: nowrap;
}
}
tbody tr {
background-color: var(--background-color);
transition: background-color 0.2s;
&:nth-child(even) {
background-color: var(--background-70);
}
&:hover {
background-color: var(--background-ui);
}
&.processing {
opacity: 0.6;
pointer-events: none;
}
}
}
}
.torrent-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include mobile-only {
max-width: none;
white-space: normal;
overflow: visible;
}
&__title {
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
}
&__meta {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.7rem;
color: var(--text-color-60);
margin-top: 0.25rem;
.meta-item {
white-space: nowrap;
}
.meta-separator {
color: var(--text-color-40);
}
.status-badge {
margin: 0;
}
}
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 500;
@include mobile-only {
font-size: 0.6rem;
padding: 0.2rem 0.35rem;
}
&--seeding {
background-color: var(--color-success);
color: var(--color-success-text);
}
&--downloading {
background-color: var(--color-warning);
color: $black;
}
&--paused {
background-color: var(--background-40);
color: $text-color-70;
}
&--stopped {
background-color: var(--color-error);
color: $white;
}
}
.actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.35rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background-color: var(--background-40);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--danger:hover:not(:disabled) {
background-color: var(--color-error);
svg {
fill: $white;
}
}
svg {
width: 16px;
height: 16px;
fill: $text-color;
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.torrent-count {
font-size: 0.8rem;
color: $text-color-50;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -10,7 +10,6 @@
>
<IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" />
<IconPerson v-if="result.type == 'person'" class="type-icon" />
<span class="title">{{ result.title }}</span>
</li>
@@ -24,25 +23,17 @@
</transition>
</template>
<!--
Searches Elasticsearch for results based on changes to `query`.
-->
<script setup lang="ts">
import { ref, watch } from "vue";
import { useStore } from "vuex";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconPerson from "@/icons/IconPerson.vue";
import type { Ref } from "vue";
import { ref, watch, defineProps } from "vue";
import { useStore } from "vuex";
import IconMovie from "../../icons/IconMovie.vue";
import IconShow from "../../icons/IconShow.vue";
import { elasticSearchMoviesAndShows } from "../../api";
import { MediaTypes } from "../../interfaces/IList";
import type {
IAutocompleteResult,
IAutocompleteSearchResults,
Hit,
Option,
Source
IAutocompleteSearchResults
} from "../../interfaces/IAutocompleteSearch";
interface Props {
@@ -56,7 +47,6 @@ Searches Elasticsearch for results based on changes to `query`.
}
const numberOfResults = 10;
let timeoutId = null;
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const store = useStore();
@@ -64,9 +54,25 @@ Searches Elasticsearch for results based on changes to `query`.
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0);
let disableOnFailure = false;
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0 && !disableOnFailure)
fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
function removeDuplicates(_searchResults: Array<IAutocompleteResult>) {
const filteredResults = [];
_searchResults.forEach((result: IAutocompleteResult) => {
_searchResults.forEach(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id
@@ -81,89 +87,65 @@ Searches Elasticsearch for results based on changes to `query`.
return filteredResults;
}
function convertMediaType(type: string | null): MediaTypes | null {
function elasticTypeToMediaType(type: string): MediaTypes {
if (type === "movie") return MediaTypes.Movie;
if (type === "tv_series") return MediaTypes.Show;
if (type === "person") return MediaTypes.Person;
return null;
}
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const elasticResults = elasticResponse.hits.hits;
const suggestResults = elasticResponse.suggest["movie-suggest"][0].options;
let data: Array<Source> = elasticResults.map((el: Hit) => el._source);
data = data.concat(suggestResults.map((el: Option) => el._source));
// data = data.concat(elasticResponse['suggest']['person-suggest'][0]['options'])
// data = data.concat(elasticResponse['suggest']['show-suggest'][0]['options'])
data = data.sort((a, b) => (a.popularity < b.popularity ? 1 : -1));
const { hits } = elasticResponse.hits;
const data = hits.length > 0 ? hits : (searchResults.value ?? []);
const results: Array<IAutocompleteResult> = [];
data.forEach(item => {
if (!item._index) return;
results.push({
title: item?.original_name || item?.original_title || item?.name,
id: item.id,
adult: item.adult,
type: convertMediaType(item?.type)
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: elasticTypeToMediaType(item._source.type)
});
});
return removeDuplicates(results)
.map((el, index) => {
return { ...el, index };
})
.slice(0, 10);
return removeDuplicates(results).map((el, index) => {
return { ...el, index };
});
}
function fetchAutocompleteResults() {
async function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0;
searchResults.value = [];
elasticSearchMoviesAndShows(props.query, numberOfResults)
return elasticSearchMoviesAndShows(props.query, numberOfResults)
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
})
.then(elasticResponse => parseElasticResponse(elasticResponse))
.then(_searchResults => {
console.log(_searchResults);
emit("update:results", _searchResults);
searchResults.value = _searchResults;
})
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
});
}
const debounce = (callback: () => void, wait: number) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback();
}, wait);
};
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0) {
debounce(fetchAutocompleteResults, 150);
}
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
// on load functions
fetchAutocompleteResults();
// end on load functions
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
$sizes: 22;
@for $i from 0 through $sizes {
@@ -241,7 +223,9 @@ Searches Elasticsearch for results based on changes to `query`.
cursor: pointer;
white-space: nowrap;
transition: color 0.1s ease, fill 0.4s ease;
transition:
color 0.1s ease,
fill 0.4s ease;
span {
overflow-x: hidden;

View File

@@ -61,8 +61,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.spacer {
@include mobile-only {

View File

@@ -37,18 +37,24 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.navigation-link {
display: grid;
place-items: center;
min-height: var(--header-size);
height: var(--header-size);
width: var(--header-size);
list-style: none;
padding: 1rem 0.15rem;
text-align: center;
background-color: var(--background-color-secondary);
transition: transform 0.3s ease, color 0.3s ease, stoke 0.3s ease,
fill 0.3s ease, background-color 0.5s ease;
transition:
transform 0.3s ease,
color 0.3s ease,
stoke 0.3s ease,
fill 0.3s ease,
background-color 0.5s ease;
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);

View File

@@ -77,7 +77,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.navigation-icons {
display: grid;
@@ -90,10 +90,18 @@
@include desktop {
grid-template-rows: var(--header-size);
grid-auto-flow: row;
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
:global(.navigation-icons > *:last-child) {
margin-top: auto;
justify-self: end;
align-self: end;
background-color: red;
}
</style>

View File

@@ -55,22 +55,14 @@ the `query`.
-->
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, computed } from "vue";
import { useStore } from "vuex";
import { useRouter, useRoute } from "vue-router";
import AutocompleteDropdown from "@/components/header/AutocompleteDropdown.vue";
import IconSearch from "@/icons/IconSearch.vue";
import IconClose from "@/icons/IconClose.vue";
import type { Ref } from "vue";
import type { MediaTypes } from "../../interfaces/IList";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
interface ISearchResult {
title: string;
id: number;
adult: boolean;
type: MediaTypes;
}
import AutocompleteDropdown from "./AutocompleteDropdown.vue";
import IconSearch from "../../icons/IconSearch.vue";
import IconClose from "../../icons/IconClose.vue";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
const store = useStore();
const router = useRouter();
@@ -98,13 +90,9 @@ import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
query.value = decodeURIComponent(params.get("query"));
}
const { ELASTIC, ELASTIC_APIKEY } = process.env;
if (
ELASTIC === undefined ||
ELASTIC === "" ||
ELASTIC_APIKEY === undefined ||
ELASTIC_APIKEY === ""
) {
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
disabled.value = true;
}
@@ -184,9 +172,9 @@ import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.close-icon {
position: absolute;

View File

@@ -0,0 +1,149 @@
<template>
<div class="plex-connect">
<div class="info-box">
<IconInfo class="info-icon" />
<p>
Sign in to your Plex account to get information about recently added
movies and to see your watch history
</p>
</div>
<div class="signin-container">
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
<svg
v-if="!loading"
width="20"
height="20"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
/>
</svg>
{{ loading ? "Connecting..." : "Sign in with Plex" }}
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
</div>
</template>
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
const emit = defineEmits<{
authSuccess: [token: string];
authError: [message: string];
}>();
const { loading, openAuthPopup } = usePlexAuth();
function handleAuth() {
openAuthPopup(
token => emit("authSuccess", token),
error => emit("authError", error)
);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.info-box {
display: flex;
gap: 0.65rem;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.85rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.6rem;
gap: 0.55rem;
margin-bottom: 0.7rem;
}
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.info-icon {
width: 20px;
height: 20px;
fill: var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 18px;
height: 18px;
}
}
.signin-container {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.plex-signin-btn {
padding: 1rem 1.75rem;
background-color: #c87818;
color: $white;
border: none;
border-radius: 0.75rem;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
align-self: flex-start;
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.25);
letter-spacing: -0.01em;
@include mobile-only {
width: 100%;
padding: 0.9rem 1.4rem;
font-size: 1rem;
}
&:hover:not(:disabled) {
background-color: #b36a15;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(200, 120, 24, 0.4);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
svg {
flex-shrink: 0;
width: 22px;
height: 22px;
}
}
.popup-note {
margin: 0;
font-size: 0.85rem;
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<a
v-if="item.plexUrl"
:href="item.plexUrl"
target="_blank"
rel="noopener noreferrer"
class="plex-library-item"
>
<figure class="item-poster">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</a>
<div v-else class="plex-library-item plex-library-item--no-link">
<figure class="item-poster">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
interface LibraryItem {
title: string;
poster?: string;
fallbackIcon?: string;
year?: number;
rating?: number;
artist?: string;
episodes?: number;
tracks?: number;
plexUrl?: string | null;
}
interface Props {
item: LibraryItem;
showExtras?: boolean;
}
const props = defineProps<Props>();
const fallbackIconComponent = computed(() => {
if (props.item.fallbackIcon === "🎬") return IconMovie;
if (props.item.fallbackIcon === "📺") return IconShow;
if (props.item.fallbackIcon === "🎵") return IconMusic;
return IconMovie; // Default fallback
});
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}
</script>
<style scoped>
.plex-library-item {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: transform 0.2s;
text-decoration: none;
color: inherit;
}
.plex-library-item:hover {
transform: translateY(-4px);
}
.plex-library-item--no-link {
cursor: default;
}
.plex-library-item--no-link:hover {
transform: none;
}
.item-poster {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
overflow: hidden;
background: #333;
margin: 0;
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333 0%, #222 100%);
padding: 20%;
svg {
width: 100%;
height: 100%;
fill: #666;
}
}
.item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.item-year {
color: #aaa;
}
.item-rating {
display: flex;
align-items: center;
gap: 4px;
color: #fbbf24;
}
.item-extras {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #888;
}
@media (max-width: 768px) {
.item-title {
font-size: 13px;
}
.item-meta {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<div class="modal-overlay library-modal-overlay" @click="emit('close')">
<div class="library-modal-content" @click.stop>
<div class="library-modal-header">
<div class="library-header-title">
<div class="library-icon-large">
<component :is="libraryIconComponent" />
</div>
<div>
<h3>{{ getLibraryTitle(libraryType) }}</h3>
<p class="library-subtitle">{{ details.total }} items</p>
</div>
</div>
<button class="close-btn" @click="emit('close')">
<IconClose />
</button>
</div>
<div class="library-modal-body">
<!-- Stats Overview -->
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{ details.total }}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'shows'">
<span class="overview-label">Episodes</span>
<span class="overview-value">{{ details.totalEpisodes }}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'music'">
<span class="overview-label">Tracks</span>
<span class="overview-value">{{ details.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{ details.totalDuration }}</span>
</div>
</div>
<!-- Recently Added -->
<div class="library-section">
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in details.recentlyAdded"
:key="index"
:item="item"
:show-extras="libraryType === 'music' || libraryType === 'shows'"
/>
</div>
</div>
<!-- Top Genres -->
<div class="library-section">
<h4 class="section-title">Top Genres</h4>
<div class="genre-list">
<div
v-for="(genre, index) in details.genres"
:key="index"
class="genre-item"
>
<span class="genre-name">{{ genre.name }}</span>
<div class="genre-bar-container">
<div
class="genre-bar"
:style="{
width: (genre.count / details.total) * 100 + '%'
}"
></div>
</div>
<span class="genre-count">{{ genre.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
import { getLibraryTitle } from "@/utils/plexHelpers";
interface LibraryDetails {
total: number;
recentlyAdded: any[];
genres: { name: string; count: number }[];
totalDuration: string;
totalEpisodes?: number;
totalTracks?: number;
}
interface Props {
libraryType: string;
details: LibraryDetails;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
if (props.libraryType === "movies") return IconMovie;
if (props.libraryType === "shows") return IconShow;
if (props.libraryType === "music") return IconMusic;
return IconMovie;
});
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
}
window.addEventListener("keyup", checkEventForEscapeKey);
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.library-modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.library-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
border-bottom: 1px solid #333;
}
.library-header-title {
display: flex;
align-items: center;
gap: 16px;
}
.library-icon-large {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
}
}
.library-modal-header h3 {
margin: 0;
font-size: 24px;
color: #fff;
}
.library-subtitle {
margin: 4px 0 0;
font-size: 14px;
color: #888;
}
.close-btn {
--size: 2.4rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
}
.close-btn:hover {
background: #333;
color: #fff;
}
.library-modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.library-stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.overview-stat {
background: #252525;
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.overview-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overview-value {
font-size: 24px;
font-weight: 600;
color: #fff;
}
.library-section {
margin-bottom: 32px;
}
.section-title {
margin: 0 0 16px;
font-size: 18px;
color: #fff;
font-weight: 600;
}
.recent-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
}
.genre-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.genre-item {
display: grid;
grid-template-columns: 120px 1fr 60px;
align-items: center;
gap: 12px;
}
.genre-name {
font-size: 14px;
color: #fff;
}
.genre-bar-container {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.genre-bar {
height: 100%;
background: linear-gradient(90deg, #e5a00d 0%, #ffbf3f 100%);
border-radius: 4px;
transition: width 0.3s ease;
}
.genre-count {
font-size: 14px;
color: #888;
text-align: right;
}
@media (max-width: 768px) {
.library-stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.recent-items-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 16px;
}
.genre-item {
grid-template-columns: 100px 1fr 50px;
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="library-stats">
<div
v-for="stat in displayStats"
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
"
>
<div class="stat-icon">
<component :is="stat.icon" />
</div>
<div class="stat-content">
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import IconClock from "@/icons/IconClock.vue";
interface Props {
movies: number;
shows: number;
music: number;
watchtime: number;
loading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
openLibrary: [type: string];
}>();
const displayStats = computed(() => [
{
key: "movies",
icon: IconMovie,
value: props.movies,
label: "Movies",
clickable: true
},
{
key: "shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",
clickable: true
},
{
key: "music",
icon: IconMusic,
value: props.music,
label: "Albums",
clickable: true
},
{
key: "watchtime",
icon: IconClock,
value: props.watchtime,
label: "Hours Watched",
clickable: false
}
]);
function handleClick(type: string) {
emit("openLibrary", type);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.library-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.85rem;
@include tablet-only {
grid-template-columns: repeat(3, 1fr);
}
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.65rem;
}
}
.stat-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
border: 1px solid transparent;
@include mobile-only {
padding: 0.85rem 0.75rem;
}
&:hover:not(.disabled, .unclickable) {
background-color: var(--background-40);
border-color: var(--highlight-color);
cursor: pointer;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.disabled {
opacity: 0.6;
&:hover {
transform: none;
border-color: transparent;
}
}
}
.stat-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
@include mobile-only {
width: 2rem;
height: 2rem;
}
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
transition: fill 0.2s ease;
}
}
.stat-card:hover:not(.disabled) .stat-icon svg {
fill: var(--color-green-90);
}
.stat-content {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
line-height: 1;
margin-bottom: 0.25rem;
@include mobile-only {
font-size: 1.3rem;
}
}
.stat-label {
font-size: 0.75rem;
color: var(--text-color-60);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
@include mobile-only {
font-size: 0.7rem;
}
}
.loading-dots {
animation: loadingDots 1.5s infinite;
}
@keyframes loadingDots {
0%,
20% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div v-if="username" class="plex-profile-card">
<div class="profile-header">
<img
v-if="userData?.thumb"
:src="userData.thumb"
alt="Profile"
class="profile-avatar"
/>
<div v-else class="profile-avatar-placeholder">
{{ username.charAt(0).toUpperCase() }}
</div>
<div class="profile-info">
<div class="username-row">
<h3 class="profile-username">{{ username }}</h3>
<svg
class="connected-checkmark"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<div v-if="userData?.email" class="profile-email">
{{ userData.email }}
</div>
<div class="profile-badges">
<div
v-if="userData?.subscription?.active"
class="profile-badge plex-pass"
>
<svg
width="14"
height="14"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 230.4C66.9 230.4 25.6 189.1 25.6 128S66.9 25.6 128 25.6 230.4 66.9 230.4 128 189.1 230.4 128 230.4z"
/>
</svg>
Plex Pass
</div>
<div v-if="userData?.joined_at" class="profile-badge member-since">
{{ formatMemberSince(userData.joined_at) }}
</div>
<div
v-if="userData?.two_factor_enabled"
class="profile-badge two-factor"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
2FA
</div>
<div
v-if="userData?.experimental_features"
class="profile-badge experimental"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
Labs
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
username: string;
userData: any;
}
defineProps<Props>();
function formatMemberSince(dateString: string) {
try {
const date = new Date(dateString);
const now = new Date();
const years = now.getFullYear() - date.getFullYear();
if (years === 0) return "New Member";
if (years === 1) return "1 Year";
return `${years} Years`;
} catch {
return "Member";
}
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-profile-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.85rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.85rem;
}
}
.profile-header {
display: flex;
gap: 0.85rem;
align-items: center;
}
.profile-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
}
}
.profile-avatar-placeholder {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--background-40)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
font-size: 1.3rem;
}
}
.profile-info {
flex: 1;
min-width: 0;
}
.username-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.profile-username {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1.1rem;
}
}
.connected-checkmark {
color: var(--color-success-highlight);
flex-shrink: 0;
animation: checkmarkPop 0.3s ease-out;
@include mobile-only {
width: 18px;
height: 18px;
}
}
@keyframes checkmarkPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.profile-email {
font-size: 0.85rem;
color: var(--text-color-60);
margin-bottom: 0.4rem;
word-break: break-all;
@include mobile-only {
font-size: 0.8rem;
}
}
.profile-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.profile-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
@include mobile-only {
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
}
&.plex-pass {
background-color: #cc7b19;
color: $white;
text-transform: uppercase;
svg {
width: 12px;
height: 12px;
@include mobile-only {
width: 11px;
height: 11px;
}
}
}
&.member-since {
background-color: var(--background-40);
color: var(--text-color-70);
}
&.two-factor {
background-color: var(--color-success);
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
&.experimental {
background-color: #8b5cf6;
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="plex-server-info">
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
Server
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
</div>
</div>
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<svg
v-if="!syncing"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
Unlink Account
</seasoned-button>
</div>
</div>
</template>
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
interface Props {
serverName: string;
lastSync: string;
syncing?: boolean;
}
defineProps<Props>();
defineEmits<{
sync: [];
unlink: [];
}>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-details {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.85rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.55rem 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.5rem 0.6rem;
}
}
.detail-label {
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
font-size: 0.8rem;
}
svg {
color: var(--text-color-60);
flex-shrink: 0;
}
}
.detail-value {
font-size: 0.95rem;
@include mobile-only {
font-size: 0.9rem;
}
}
.plex-actions {
display: flex;
gap: 0.65rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>Unlink Plex Account</h3>
<button class="close-btn" @click="emit('cancel')">
<IconClose />
</button>
</div>
<div class="modal-body">
<p>
Are you sure you want to unlink your Plex account? You will lose
access to:
</p>
<ul>
<li>Watch history tracking</li>
<li>Recently added content notifications</li>
<li>Real-time download progress</li>
</ul>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="emit('cancel')">Cancel</button>
<button class="confirm-btn" @click="emit('confirm')">
Unlink Account
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconClose from "@/icons/IconClose.vue";
const emit = defineEmits<{
(e: "confirm"): void;
(e: "cancel"): void;
}>();
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
font-size: 20px;
color: #fff;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s;
}
.close-btn:hover {
background: #333;
color: #fff;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 16px;
color: #ccc;
font-size: 14px;
line-height: 1.6;
}
.modal-body ul {
margin: 0;
padding-left: 20px;
color: #aaa;
font-size: 14px;
line-height: 1.8;
}
.modal-body li {
margin-bottom: 8px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #333;
}
.cancel-btn,
.confirm-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cancel-btn {
background: #333;
color: #fff;
}
.cancel-btn:hover {
background: #444;
}
.confirm-btn {
background: #dc2626;
color: #fff;
}
.confirm-btn:hover {
background: #b91c1c;
}
</style>

View File

@@ -2,8 +2,12 @@
<li
class="sidebar-list-element"
:class="{ active, disabled }"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-disabled="disabled"
@click="emit('click')"
@keydown.enter="emit('click')"
@keydown.enter.prevent="emit('click')"
@keydown.space.prevent="emit('click')"
>
<slot></slot>
</li>
@@ -24,7 +28,7 @@
</script>
<style lang="scss">
@import "src/scss/media-queries";
@import "scss/media-queries";
li.sidebar-list-element {
display: flex;
@@ -53,8 +57,10 @@
}
&:hover,
&:focus,
&.active {
color: var(--text-color);
outline: none;
div > svg,
svg {
@@ -63,9 +69,15 @@
}
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
border-radius: 4px;
}
&.active > div > svg,
&.active > svg {
fill: var(--color-green);
fill: var(--highlight-color);
}
&.disabled {

View File

@@ -27,7 +27,6 @@
const overflow: Ref<boolean> = ref(false);
const descriptionElement: Ref<HTMLElement> = ref(null);
// eslint-disable-next-line no-undef
function removeElements(elems: NodeListOf<Element>) {
elems.forEach(el => el.remove());
}
@@ -67,7 +66,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.movie-description {
font-weight: 300;
@@ -93,6 +92,7 @@
border: none;
background: none;
width: 100%;
height: 30px;
display: flex;
align-items: center;
text-align: center;

View File

@@ -17,7 +17,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.movie-detail {
margin-bottom: 20px;
@@ -35,7 +35,7 @@
font-weight: 400;
text-transform: uppercase;
font-size: 1.2rem;
color: var(--color-green);
color: var(--highlight-color);
@include mobile {
font-size: 1.1rem;

View File

@@ -167,23 +167,24 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
// import img from "@/directives/v-image";
import IconProfile from "@/icons/IconProfile.vue";
import IconThumbsUp from "@/icons/IconThumbsUp.vue";
import IconThumbsDown from "@/icons/IconThumbsDown.vue";
import IconInfo from "@/icons/IconInfo.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconRequested from "@/icons/IconRequested.vue";
import IconBinoculars from "@/icons/IconBinoculars.vue";
import IconPlay from "@/icons/IconPlay.vue";
import TorrentList from "@/components/torrent/TruncatedTorrentResults.vue";
import CastList from "@/components/CastList.vue";
import Detail from "@/components/popup/Detail.vue";
import ActionButton from "@/components/popup/ActionButton.vue";
import Description from "@/components/popup/Description.vue";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue";
import type { Ref } from "vue";
import IconProfile from "../../icons/IconProfile.vue";
import IconThumbsUp from "../../icons/IconThumbsUp.vue";
import IconThumbsDown from "../../icons/IconThumbsDown.vue";
import IconInfo from "../../icons/IconInfo.vue";
import IconRequest from "../../icons/IconRequest.vue";
import IconRequested from "../../icons/IconRequested.vue";
import IconBinoculars from "../../icons/IconBinoculars.vue";
import IconPlay from "../../icons/IconPlay.vue";
import TorrentList from "../torrent/TruncatedTorrentResults.vue";
import CastList from "../CastList.vue";
import Detail from "./Detail.vue";
import ActionButton from "./ActionButton.vue";
import Description from "./Description.vue";
import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue";
import type { IColors } from "../../interfaces/IColors.ts";
import type {
IMovie,
IShow,
@@ -214,6 +215,7 @@
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_URL = "https://colors.schleppe.cloud/colors";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
@@ -234,6 +236,8 @@
if (!media.value) return "/assets/placeholder.png";
if (!media.value?.poster) return "/assets/no-image.svg";
// compute & update highlight colors from poster image
colorsFromPoster(media.value.poster); // eslint-disable-line
return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`;
});
@@ -332,6 +336,34 @@
const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`;
window.location.href = tmdbURL;
}
function colorMain(colors: IColors) {
const parent = document.getElementsByClassName(
"movie-popup"
)[0] as HTMLElement;
parent.style.setProperty("--highlight-color", colors.s ?? colors.p);
parent.style.setProperty("--highlight-bg", colors.bg);
parent.style.setProperty("--highlight-secondary", colors.p);
parent.style.setProperty("--text-color", "#ffffff");
parent.style.setProperty("--text-color-90", "rgba(255, 255, 255, 0.9)");
parent.style.setProperty("--text-color-70", "rgba(255, 255, 255, 0.7)");
parent.style.setProperty("--text-color-50", "rgba(255, 255, 255, 0.5)");
parent.style.setProperty("--text-color-10", "rgba(255, 255, 255, 0.1)");
parent.style.setProperty("--text-color-5", "rgba(255, 255, 255, 0.05)");
}
async function colorsFromPoster(posterPath: string) {
const url = new URL(COLORS_URL);
url.searchParams.append("id", posterPath.replace("/", ""));
url.searchParams.append("size", "w342");
fetch(url.href)
.then(resp => {
if (resp.ok) return resp.json();
throw new Error(`invalid status: '${resp.status}' from server.`);
})
.then(colorMain)
.catch(error => console.log("unable to get colors, error:", error)); // eslint-disable-line no-console
}
// On created functions
fetchMedia();
@@ -342,10 +374,10 @@
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
header {
$duration: 0.2s;
@@ -392,6 +424,7 @@
.movie__poster {
display: none;
border-radius: 1.6rem;
@include desktop {
background: var(--background-color);
@@ -402,7 +435,7 @@
> img {
width: 100%;
border-radius: 10px;
border-radius: inherit;
}
}
}
@@ -421,8 +454,8 @@
flex-direction: row;
}
background-color: $background-color;
color: $text-color;
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
}
}
@@ -431,7 +464,9 @@
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease;
transition:
opacity 0.5s ease,
transform 0.5s ease;
&.is-loaded {
opacity: 1;
@@ -450,21 +485,26 @@
text-align: left;
padding: 140px 30px 0 40px;
}
h1 {
color: var(--color-green);
color: var(--highlight-color);
font-weight: 500;
line-height: 1.4;
font-size: 24px;
line-height: 1.2;
font-size: 2.2rem;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 0;
@include tablet-min {
font-size: 30px;
font-size: 2.2rem;
}
}
i {
display: block;
color: rgba(255, 255, 255, 0.8);
color: var(--highlight-secondary);
margin-top: 1rem;
}
}
@@ -474,7 +514,7 @@
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid $text-color-5;
border-top: 1px solid var(--text-color-50);
@include tablet-min {
order: 1;
width: 45%;
@@ -533,7 +573,7 @@
}
.torrents {
background-color: var(--background-color);
background-color: var(--highlight-bg, var(--background-color));
padding: 0 1rem;
@include mobile {

View File

@@ -51,7 +51,7 @@
</Detail>
<Detail
v-if="creditedShows.length"
v-if="creditedMovies.length"
title="movies"
:detail="`Credited in ${creditedMovies.length} movies`"
>
@@ -165,10 +165,10 @@
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
section.person {
overflow: hidden;

View File

@@ -1,31 +1,51 @@
<template>
<div>
<h3 class="settings__header">Change password</h3>
<form class="form">
<seasoned-input
v-model="oldPassword"
placeholder="old password"
icon="Keyhole"
type="password"
/>
<div class="change-password">
<div class="password-card">
<p class="password-info">
Update your password to keep your account secure. Use a strong password
with at least 8 characters.
</p>
<seasoned-input
v-model="newPassword"
placeholder="new password"
icon="Keyhole"
type="password"
/>
<form class="password-form" @submit.prevent="changePassword">
<seasoned-input
v-model="oldPassword"
placeholder="Current password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPasswordRepeat"
placeholder="repeat new password"
icon="Keyhole"
type="password"
/>
<div class="password-generator">
<button class="generator-toggle" @click="toggleGenerator">
<IconKey class="toggle-icon" />
<span>{{
showGenerator ? "Hide" : "Generate Strong Password"
}}</span>
</button>
<div v-if="showGenerator">
<password-generator @password-generated="handleGeneratedPassword" />
</div>
</div>
<seasoned-button @click="changePassword">change password</seasoned-button>
</form>
<seasoned-messages v-model:messages="messages" />
<seasoned-input
v-model="newPassword"
placeholder="New password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPasswordRepeat"
placeholder="Confirm new password"
icon="Keyhole"
type="password"
/>
<seasoned-button @click="changePassword" :disabled="loading">
{{ loading ? "Updating..." : "Change Password" }}
</seasoned-button>
</form>
<seasoned-messages v-model:messages="messages" />
</div>
</div>
</template>
@@ -34,19 +54,23 @@
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
import IconKey from "@/icons/IconKey.vue";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
// interface ResetPasswordPayload {
// old_password: string;
// new_password: string;
// }
const showGenerator = ref(false);
const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const loading = ref(false);
function handleGeneratedPassword(password: string) {
newPassword.value = password;
newPasswordRepeat.value = password;
}
function addWarningMessage(message: string, title?: string) {
messages.value.push({
@@ -57,6 +81,7 @@
}
function validate() {
return;
return new Promise((resolve, reject) => {
if (!oldPassword.value || oldPassword?.value?.length === 0) {
addWarningMessage("Missing old password!", "Validation error");
@@ -80,19 +105,103 @@
});
}
// TODO seasoned-api /user/password-reset
async function changePassword() {
try {
validate();
} catch (error) {
console.log("not valid!"); // eslint-disable-line no-console
}
await validate();
// const body: ResetPasswordPayload = {
// old_password: oldPassword.value,
// new_password: newPassword.value
// };
// const options = {};
// fetch()
loading.value = true;
// API call disabled for now
// TODO: Implement actual password change API call
// await api.changePassword({ oldPassword, newPassword });
messages.value.push({
message: "Password change is currently disabled",
title: "Feature Disabled",
type: ErrorMessageTypes.Warning
} as IErrorMessage);
// Clear form
oldPassword.value = "";
newPassword.value = "";
newPasswordRepeat.value = "";
loading.value = false;
} catch (error) {
console.log("not valid! error:", error); // eslint-disable-line no-console
loading.value = false;
}
}
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
/*
if (showGenerator.value && !generatedPassword.value) {
generateWordsPassword();
}
*/
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.password-info {
margin: 0;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
font-size: 0.9rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.6rem;
font-size: 0.85rem;
}
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.generator-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--background-ui);
border: 1px solid var(--background-40);
border-radius: 0.5rem;
color: $text-color;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
.toggle-icon {
width: 18px;
height: 18px;
fill: var(--highlight-color);
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="danger-zone">
<h3 class="danger-zone__title">{{ title }}</h3>
<p class="danger-zone__description">
{{ description }}
</p>
<button class="danger-zone__button" @click="$emit('action')">
{{ buttonText }}
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
description: string;
buttonText: string;
}
interface Emit {
(e: "action"): void;
}
defineProps<Props>();
defineEmits<Emit>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.danger-zone {
padding: 1.25rem;
background: rgba(220, 48, 35, 0.1);
border: 1px solid var(--color-error-highlight);
border-radius: 0.5rem;
@include mobile-only {
padding: 1rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-error-highlight);
}
&__description {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--text-color-70);
line-height: 1.5;
}
&__button {
padding: 0.625rem 1.25rem;
background: var(--color-error);
color: white;
border: 1px solid var(--color-error-highlight);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-highlight);
}
}
}
</style>

View File

@@ -0,0 +1,857 @@
<template>
<div class="data-export">
<!-- Info Header -->
<div class="data-export__header">
<div class="data-export__info">
<IconInfo class="info-icon" />
<span>
Full transparency and control over your data. Everything is stored
locally on your deviceno servers, no tracking. You own your data.
</span>
</div>
</div>
<div class="export-options">
<!-- Export Data Card -->
<div class="export-card">
<div class="export-header">
<h4>Export Your Data</h4>
<p>
Download a copy of your account data including requests, watch
history, and preferences.
</p>
</div>
<div class="export-actions">
<button
class="export-btn"
@click="exportData('json')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as JSON</span>
</button>
<button
class="export-btn"
@click="exportData('csv')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as CSV</span>
</button>
</div>
</div>
<!-- Request History Card -->
<div class="export-card">
<div class="export-header">
<h4>Request History</h4>
<p>View and download your complete request history.</p>
</div>
<div class="stats-grid">
<div class="stat-mini">
<span class="stat-mini__value">{{ requestStats.total }}</span>
<span class="stat-mini__label">Total</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ requestStats.approved }}</span>
<span class="stat-mini__label">Approved</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ requestStats.pending }}</span>
<span class="stat-mini__label">Pending</span>
</div>
</div>
<button class="view-btn" @click="viewHistory">View Full History</button>
</div>
<!-- Local Storage Items -->
<div class="storage-section">
<h4 class="storage-section__title">Browser Storage</h4>
<div class="storage-items">
<div
v-for="item in storageItems"
:key="item.key"
class="storage-item"
>
<div class="storage-item__info">
<h5 class="storage-item__title">{{ item.title }}</h5>
<p class="storage-item__description">
{{ item.description }} ·
<span class="storage-item__size">{{ item.size }}</span>
</p>
</div>
<button
class="storage-item__delete"
@click="clearItem(item.key, item.title)"
:title="`Clear ${item.title}`"
>
<IconClose />
</button>
</div>
</div>
</div>
<!-- Clear All Local Data -->
<DangerZoneAction
title="Clear All Local Data"
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
button-text="Clear All Data"
@action="clearAllData"
/>
<!-- Delete Account -->
<DangerZoneAction
title="Delete Account"
description="Permanently delete your account and all associated data. This action cannot be undone."
button-text="Delete My Account"
@action="confirmDelete"
/>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click="cancelDelete">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>Delete Account</h3>
<button class="close-btn" @click="cancelDelete">
<IconClose />
</button>
</div>
<div class="modal-body">
<div class="warning-box">
<span class="warning-icon"></span>
<p>
<strong>Warning:</strong> This action is permanent and cannot be
undone.
</p>
</div>
<p>All of the following will be permanently deleted:</p>
<ul>
<li>Your account and profile information</li>
<li>All request history</li>
<li>Watch history and preferences</li>
<li>Plex account connection</li>
</ul>
<p>Type <strong>DELETE</strong> to confirm:</p>
<input
v-model="deleteConfirmation"
type="text"
placeholder="Type DELETE"
class="confirm-input"
/>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="cancelDelete">Cancel</button>
<button
class="confirm-delete-btn"
@click="deleteAccount"
:disabled="deleteConfirmation !== 'DELETE'"
>
Delete Account
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { useRouter } from "vue-router";
import { clearCommandHistory } from "@/utils/commandTracking";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
interface StorageItem {
key: string;
title: string;
description: string;
size: string;
}
const router = useRouter();
const notifications: {
success: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
error: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
} = inject("notifications");
const exporting = ref(false);
const showDeleteModal = ref(false);
const deleteConfirmation = ref("");
const requestStats = ref({
total: 45,
approved: 38,
pending: 7
});
const storageItems = computed<StorageItem[]>(() => {
const items: StorageItem[] = [];
// Command palette stats
const commandStats = localStorage.getItem("commandPalette_stats");
if (commandStats) {
items.push({
key: "commandPalette_stats",
title: "Command Palette History",
description: "Usage statistics for command palette navigation",
size: formatBytes(commandStats.length)
});
}
// Plex user data
const plexData = localStorage.getItem("plex_user_data");
if (plexData) {
items.push({
key: "plex_user_data",
title: "Plex User Data",
description: "Cached Plex account information",
size: formatBytes(plexData.length)
});
}
// Theme preference
const theme = localStorage.getItem("theme");
if (theme) {
items.push({
key: "theme",
title: "Theme Preference",
description: "Your selected color theme",
size: formatBytes(theme.length)
});
}
// Color scheme
const colorScheme = localStorage.getItem("color-scheme");
if (colorScheme) {
items.push({
key: "color-scheme",
title: "Color Scheme",
description: "Light or dark mode preference",
size: formatBytes(colorScheme.length)
});
}
return items;
});
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
async function exportData(format: "json" | "csv") {
exporting.value = true;
// Mock export
await new Promise(resolve => setTimeout(resolve, 1500));
const data = {
username: "user123",
requests: requestStats.value,
exportDate: new Date().toISOString()
};
const blob = new Blob(
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
{ type: format === "json" ? "application/json" : "text/csv" }
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `seasoned-data-export.${format}`;
link.click();
URL.revokeObjectURL(url);
exporting.value = false;
}
function convertToCSV(data: any): string {
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
}
function viewHistory() {
router.push({ name: "profile" });
}
function confirmDelete() {
showDeleteModal.value = true;
deleteConfirmation.value = "";
}
function cancelDelete() {
showDeleteModal.value = false;
deleteConfirmation.value = "";
}
function deleteAccount() {
if (deleteConfirmation.value === "DELETE") {
alert("Account deletion would be processed here");
showDeleteModal.value = false;
}
}
function clearItem(key: string, title: string) {
try {
// Special handling for command history
if (key === "commandPalette_stats") {
clearCommandHistory();
} else {
localStorage.removeItem(key);
}
notifications.success({
title: "Data Cleared",
description: `${title} has been cleared`,
timeout: 3000
});
// Force re-render
storageItems.value;
} catch (error) {
notifications.error({
title: "Error",
description: `Failed to clear ${title}`,
timeout: 5000
});
}
}
function clearAllData() {
const confirmed = confirm(
"Are you sure you want to clear all locally stored data? This action cannot be undone."
);
if (!confirmed) return;
try {
localStorage.clear();
clearCommandHistory();
notifications.success({
title: "All Data Cleared",
description: "All locally stored data has been removed",
timeout: 3000
});
} catch (error) {
notifications.error({
title: "Error",
description: "Failed to clear all data",
timeout: 5000
});
}
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.data-export {
&__header {
margin-bottom: 1rem;
@include mobile-only {
margin-bottom: 0.85rem;
}
}
&__info {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: var(--background-ui);
border-radius: 0.375rem;
border-left: 3px solid var(--highlight-color);
.info-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: var(--highlight-color);
margin-top: 2px;
}
span {
font-size: 0.85rem;
color: var(--text-color-70);
line-height: 1.5;
}
}
}
.export-options {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.export-card {
padding: 0.85rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.75rem;
}
}
.export-header {
margin-bottom: 0.85rem;
@include mobile-only {
margin-bottom: 0.7rem;
}
h4 {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 500;
@include mobile-only {
font-size: 0.95rem;
}
}
p {
margin: 0;
font-size: 0.85rem;
@include mobile-only {
font-size: 0.8rem;
}
}
}
.export-actions {
display: flex;
gap: 0.55rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.export-btn {
flex: 1;
padding: 0.55rem 0.85rem;
background-color: var(--highlight-color);
color: $white;
border: none;
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
&:hover:not(:disabled) {
background-color: var(--color-green-90);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
fill: $white;
&.spin {
animation: spin 1s linear infinite;
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.stat-mini {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.4rem;
background-color: var(--background-color);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.45rem 0.35rem;
}
&__value {
font-size: 1.2rem;
font-weight: 600;
color: var(--highlight-color);
@include mobile-only {
font-size: 1.1rem;
}
}
&__label {
font-size: 0.7rem;
text-transform: uppercase;
margin-top: 0.15rem;
@include mobile-only {
font-size: 0.65rem;
}
}
}
.view-btn {
width: 100%;
padding: 0.55rem 0.85rem;
background-color: var(--background-color);
border: 1px solid var(--background-40);
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
.storage-section {
&__title {
margin: 0 0 0.65rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
@include mobile-only {
font-size: 0.95rem;
margin-bottom: 0.6rem;
}
}
}
.storage-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.storage-item {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
.storage-item__delete {
background: var(--color-error-highlight);
}
}
&__info {
flex: 1;
min-width: 0;
padding: 0.85rem;
@include mobile-only {
padding: 0.75rem;
}
}
&__title {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 0.9rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__delete {
flex-shrink: 0;
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s ease;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s ease;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active {
svg {
transform: scale(0.9);
}
}
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
@include mobile-only {
padding: 0.5rem;
align-items: flex-end;
}
}
.modal-content {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
@include mobile-only {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 1rem;
}
h3 {
margin: 0;
font-weight: 400;
@include mobile-only {
font-size: 1rem;
}
}
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-40);
}
svg {
width: 20px;
height: 20px;
}
}
.modal-body {
padding: 1.5rem;
@include mobile-only {
padding: 1rem;
}
p {
margin: 0 0 1rem 0;
@include mobile-only {
font-size: 0.9rem;
}
}
ul {
margin: 0 0 1.5rem 0;
padding-left: 1.5rem;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.warning-box {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background-color: var(--color-warning);
border-radius: 0.5rem;
margin-bottom: 1rem;
@include mobile-only {
padding: 0.75rem;
}
.warning-icon {
font-size: 1.5rem;
}
p {
margin: 0;
color: $black;
font-size: 0.9rem;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.confirm-input {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
font-size: 0.9rem;
&:focus {
outline: none;
border-color: var(--color-error-highlight);
}
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--background-40);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
@include mobile-only {
padding: 1rem;
flex-direction: column-reverse;
}
button {
padding: 0.65rem 1.25rem;
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
}
.cancel-btn {
background-color: transparent;
border: 1px solid var(--background-40) !important;
&:hover {
background-color: var(--background-ui);
}
}
.confirm-delete-btn {
background-color: var(--color-error-highlight);
color: $white;
&:hover:not(:disabled) {
background-color: var(--color-error);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<div class="storage-manager">
<div class="storage-manager__header">
<p class="storage-manager__description">
Full transparency and control over your data. Everything listed here is
stored locally on your deviceno servers, no tracking. You own your
data.
</p>
<div class="storage-manager__info">
<IconInfo class="info-icon" />
<span
>Your browser stores this data to improve your experience. Clear
individual items or wipe everythingit's your choice.</span
>
</div>
</div>
<div class="storage-items">
<div v-for="item in storageItems" :key="item.key" class="storage-item">
<div class="storage-item__info">
<h4 class="storage-item__title">{{ item.title }}</h4>
<p class="storage-item__description">
{{ item.description }} ·
<span class="storage-item__size">{{ item.size }}</span>
</p>
</div>
<button
class="storage-item__delete"
@click="clearItem(item.key, item.title)"
:title="`Clear ${item.title}`"
>
<IconClose />
</button>
</div>
</div>
<DangerZoneAction
title="Clear Everything"
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
button-text="Clear All Data"
@action="clearAllData"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { clearCommandHistory } from "@/utils/commandTracking";
import IconInfo from "@/icons/IconInfo.vue";
import IconClose from "@/icons/IconClose.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
interface StorageItem {
key: string;
title: string;
description: string;
size: string;
}
const notifications: {
success: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
error: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
} = inject("notifications");
const storageItems = computed<StorageItem[]>(() => {
const items: StorageItem[] = [];
// Command palette stats
const commandStats = localStorage.getItem("commandPalette_stats");
if (commandStats) {
items.push({
key: "commandPalette_stats",
title: "Command Palette History",
description: "Usage statistics for command palette navigation",
size: formatBytes(commandStats.length)
});
}
// Plex user data
const plexData = localStorage.getItem("plex_user_data");
if (plexData) {
items.push({
key: "plex_user_data",
title: "Plex User Data",
description: "Cached Plex account information",
size: formatBytes(plexData.length)
});
}
// Theme preference
const theme = localStorage.getItem("theme");
if (theme) {
items.push({
key: "theme",
title: "Theme Preference",
description: "Your selected color theme",
size: formatBytes(theme.length)
});
}
// Color scheme
const colorScheme = localStorage.getItem("color-scheme");
if (colorScheme) {
items.push({
key: "color-scheme",
title: "Color Scheme",
description: "Light or dark mode preference",
size: formatBytes(colorScheme.length)
});
}
return items;
});
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
function clearItem(key: string, title: string) {
try {
// Special handling for command history
if (key === "commandPalette_stats") {
clearCommandHistory();
} else {
localStorage.removeItem(key);
}
notifications.success({
title: "Data Cleared",
description: `${title} has been cleared`,
timeout: 3000
});
// Force re-render
storageItems.value;
} catch (error) {
notifications.error({
title: "Error",
description: `Failed to clear ${title}`,
timeout: 5000
});
}
}
function clearAllData() {
const confirmed = confirm(
"Are you sure you want to clear all locally stored data? This action cannot be undone."
);
if (!confirmed) return;
try {
localStorage.clear();
clearCommandHistory();
notifications.success({
title: "All Data Cleared",
description: "All locally stored data has been removed",
timeout: 3000
});
} catch (error) {
notifications.error({
title: "Error",
description: "Failed to clear all data",
timeout: 5000
});
}
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.storage-manager {
&__header {
margin-bottom: 1.5rem;
}
&__description {
margin: 0 0 0.75rem 0;
color: var(--text-color);
font-size: 0.95rem;
line-height: 1.6;
font-weight: 500;
}
&__info {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: var(--background-ui);
border-radius: 0.375rem;
border-left: 3px solid var(--highlight-color);
.info-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: var(--highlight-color);
margin-top: 2px;
}
span {
font-size: 0.85rem;
color: var(--text-color-70);
line-height: 1.5;
}
}
}
.storage-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.storage-item {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0;
background: var(--background-ui);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
.storage-item__delete {
background: var(--color-error-highlight);
}
}
&__info {
flex: 1;
min-width: 0;
padding: 0.85rem;
@include mobile-only {
padding: 0.75rem;
}
}
&__title {
margin: 0 0 0.3rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__delete {
flex-shrink: 0;
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s ease;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s ease;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active {
svg {
transform: scale(0.9);
}
}
}
}
</style>

View File

@@ -0,0 +1,502 @@
<template>
<div class="password-generator">
<div class="generator-panel">
<div class="generator-tabs">
<button
:class="['tab', { 'tab--active': mode === 'words' }]"
@click="mode = 'words'"
>
Passphrase
</button>
<button
:class="['tab', { 'tab--active': mode === 'chars' }]"
@click="mode = 'chars'"
>
Random
</button>
</div>
<div v-if="mode === 'words'" class="generator-content">
<div class="generator-header">
<h4>Passphrase Generator</h4>
<p>Create a memorable password using random words</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<label>Number of words: {{ wordCount }}</label>
<input
v-model.number="wordCount"
type="range"
min="3"
max="7"
class="slider"
@input="generateWordsPassword"
/>
<span class="option-value">{{ wordCount }}</span>
</div>
</div>
</div>
<div v-else class="generator-content">
<div class="generator-header">
<h4>Random Password Generator</h4>
<p>Generate a secure random password</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<label>Length: {{ charLength }}</label>
<input
v-model.number="charLength"
type="range"
min="12"
max="46"
class="slider"
@input="generateCharsPassword"
/>
<span class="option-value">{{ charLength }}</span>
</div>
<div class="option-row checkbox-row">
<label>
<input
v-model="includeUppercase"
type="checkbox"
@change="generateCharsPassword"
/>
Uppercase (A-Z)
</label>
<label>
<input
v-model="includeLowercase"
type="checkbox"
@change="generateCharsPassword"
/>
Lowercase (a-z)
</label>
<label>
<input
v-model="includeNumbers"
type="checkbox"
@change="generateCharsPassword"
/>
Numbers (0-9)
</label>
<label>
<input
v-model="includeSymbols"
type="checkbox"
@change="generateCharsPassword"
/>
Symbols (!@#$)
</label>
</div>
</div>
</div>
<div class="generator-actions">
<button class="action-btn action-btn--secondary" @click="regenerate">
<IconActivity class="btn-icon" />
Regenerate
</button>
<button class="action-btn action-btn--primary" @click="usePassword">
Use This Password
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
interface Emit {
(e: "passwordGenerated", password: string): void;
}
const emit = defineEmits<Emit>();
const mode = ref<"words" | "chars">("words");
const generatedPassword = ref("");
const copied = ref(false);
// Words mode options
const wordCount = ref(4);
const separator = ref("-");
// Chars mode options
const charLength = ref(16);
const includeUppercase = ref(true);
const includeLowercase = ref(true);
const includeNumbers = ref(true);
const includeSymbols = ref(true);
const { getRandomWords } = useRandomWords();
async function generateWordsPassword() {
const words = await getRandomWords(wordCount.value);
const password = words.join(separator.value);
generatedPassword.value = password;
}
function generateCharsPassword() {
let charset = "";
if (includeUppercase.value) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (includeLowercase.value) charset += "abcdefghijklmnopqrstuvwxyz";
if (includeNumbers.value) charset += "0123456789";
if (includeSymbols.value) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (charset === "") charset = "abcdefghijklmnopqrstuvwxyz";
let password = "";
for (let i = 0; i < charLength.value; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
generatedPassword.value = password;
}
async function regenerate() {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generatedPassword.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
function usePassword() {
emit("passwordGenerated", generatedPassword.value);
// TODO: emit
// showGenerator.value = false;
}
watch(mode, async () => {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
});
onMounted(generateWordsPassword);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-generator {
margin-bottom: 1rem;
}
.generator-panel {
margin-top: 0.75rem;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.75rem;
}
}
.generator-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.65rem 1rem;
background-color: transparent;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
color: $text-color-70;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
}
&--active {
background-color: var(--highlight-color);
border-color: var(--highlight-color);
color: $white;
}
}
.generator-content {
margin-bottom: 1rem;
}
.generator-header {
margin-bottom: 0.75rem;
h4 {
margin: 0 0 0.15rem 0;
font-size: 0.95rem;
font-weight: 500;
color: $text-color;
line-height: 1.3;
}
p {
margin: 0;
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.3;
}
}
.password-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-color);
border: 2px solid var(--highlight-color);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
padding: 0.6rem;
}
}
.password-text {
flex: 1;
font-size: 1.8rem;
font-weight: 500;
color: var(--highlight-color);
user-select: all;
word-break: break-all;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
@include mobile-only {
font-size: 0.95rem;
}
&--mono {
font-family: "Courier New", monospace;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.copy-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
}
.generator-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.option-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
label {
font-size: 0.85rem;
color: $text-color;
font-weight: 500;
line-height: 1.2;
}
&.checkbox-row {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 400;
cursor: pointer;
font-size: 0.85rem;
input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
}
}
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--background-40);
outline: none;
appearance: none;
&::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight-color);
cursor: pointer;
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight-color);
cursor: pointer;
border: none;
}
}
.option-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--highlight-color);
text-align: center;
}
.separator-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
font-family: "Courier New", monospace;
text-align: center;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
&::placeholder {
color: $text-color-50;
font-family: inherit;
}
}
.generator-actions {
display: flex;
gap: 0.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.action-btn {
flex: 1;
padding: 0.6rem 1rem;
border: none;
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
&--secondary {
background-color: var(--background-color);
color: $text-color;
border: 1px solid var(--background-40);
&:hover {
background-color: var(--background-40);
}
}
&--primary {
background-color: var(--highlight-color);
color: $white;
&:hover {
background-color: var(--color-green-90);
}
}
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<div class="plex-settings">
<!-- Unconnected state -->
<PlexAuthButton
v-if="!isPlexConnected"
@auth-success="handleAuthSuccess"
@auth-error="handleAuthError"
/>
<!-- Connected state -->
<div v-else class="plex-connected">
<PlexProfileCard
v-if="plexUsername"
:username="plexUsername"
:userData="plexUserData"
/>
<PlexLibraryStats
:movies="libraryStats.movies"
:shows="libraryStats.shows"
:music="libraryStats.music"
:watchtime="libraryStats.watchtime"
:loading="loadingLibraries"
@open-library="showLibraryDetails"
/>
<PlexServerInfo
:serverName="plexServer"
:lastSync="lastSync"
:syncing="syncing"
@sync="syncLibrary"
@unlink="confirmUnlink"
/>
</div>
<!-- Messages -->
<SeasonedMessages v-model:messages="messages" />
<!-- Unlink Confirmation Modal -->
<PlexUnlinkModal
v-if="showConfirmModal"
@confirm="unauthenticatePlex"
@cancel="cancelUnlink"
/>
<!-- Library Details Modal -->
<PlexLibraryModal
v-if="showLibraryModal && selectedLibrary"
:libraryType="selectedLibrary"
:details="libraryDetails[selectedLibrary]"
@close="closeLibraryModal"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
import PlexLibraryStats from "@/components/plex/PlexLibraryStats.vue";
import PlexServerInfo from "@/components/plex/PlexServerInfo.vue";
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
import { usePlexAuth } from "@/composables/usePlexAuth";
import { usePlexApi } from "@/composables/usePlexApi";
import { usePlexLibraries } from "@/composables/usePlexLibraries";
import type { Ref } from "vue";
import { linkPlexAccount, unlinkPlexAccount } from "../../api";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const messages: Ref<IErrorMessage[]> = ref([]);
const loading = ref(false);
const syncing = ref(false);
const showConfirmModal = ref(false);
const plexUsername = ref<string>("");
const plexUserData = ref<any>(null);
const isPlexConnected = ref<boolean>(false);
const hasLocalStorageData = ref<boolean>(false);
const hasCookieData = ref<boolean>(false);
const showLibraryModal = ref<boolean>(false);
const selectedLibrary = ref<string>("");
const loadingLibraries = ref<boolean>(false);
const plexServer = ref("");
const plexServerUrl = ref("");
const plexMachineId = ref("");
const lastSync = ref("");
const libraryStats = ref({
movies: 0,
shows: 0,
music: 0,
watchtime: 0
});
const libraryDetails = ref<any>({
movies: {
total: 0,
recentlyAdded: [],
genres: [],
totalDuration: "0 hours"
},
shows: {
total: 0,
recentlyAdded: [],
genres: [],
totalEpisodes: 0,
totalDuration: "0 hours"
},
music: {
total: 0,
recentlyAdded: [],
genres: [],
totalTracks: 0
}
});
const store = useStore();
const emit = defineEmits<{
(e: "reload"): void;
}>();
// Composables
const { getCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
const {
fetchPlexUserData,
fetchPlexServers,
fetchLibrarySections,
fetchLibraryDetails
} = usePlexApi();
const { loadLibraries } = usePlexLibraries();
// ----- Connection check -----
function checkPlexConnection() {
const cachedData = localStorage.getItem("plex_user_data");
const authToken = getCookie("plex_auth_token");
const storeHasPlexUserId = store.getters["user/plexUserId"];
hasLocalStorageData.value = !!cachedData;
hasCookieData.value = !!authToken;
isPlexConnected.value = !!(cachedData || authToken || storeHasPlexUserId);
return isPlexConnected.value;
}
// ----- Library loading -----
async function fetchPlexLibraries(authToken: string) {
try {
loadingLibraries.value = true;
const server = await fetchPlexServers(authToken);
if (!server) {
console.error("No Plex server found");
return;
}
plexServer.value = server.name;
plexServerUrl.value = server.url;
plexMachineId.value = server.machineIdentifier;
lastSync.value = new Date().toLocaleString();
const sections = await fetchLibrarySections(authToken, server.url);
if (!sections || sections.length === 0) {
console.error("No library sections found");
return;
}
const result = await loadLibraries(
sections,
authToken,
server.url,
server.machineIdentifier,
plexUsername.value,
fetchLibraryDetails
);
libraryStats.value = result.stats;
libraryDetails.value = result.details;
} catch (error) {
console.error("[PlexSettings] Error fetching Plex libraries:", error);
} finally {
loadingLibraries.value = false;
}
}
// ----- User data loading -----
async function loadPlexUserData() {
checkPlexConnection();
const cachedData = localStorage.getItem("plex_user_data");
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
try {
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
isPlexConnected.value = true;
} catch (error) {
console.error("[PlexSettings] Error parsing cached Plex data:", error);
}
}
const authToken = getCookie("plex_auth_token");
hasCookieData.value = !!authToken;
if (authToken) {
const userData = await fetchPlexUserData(authToken);
if (userData) {
plexUserData.value = userData;
plexUsername.value = userData.username;
isPlexConnected.value = true;
} else if (!cachedData) {
isPlexConnected.value = false;
}
if (isPlexConnected.value) {
await fetchPlexLibraries(authToken);
}
} else {
isPlexConnected.value = false;
}
}
// ----- OAuth flow (handlers for PlexAuthButton events) -----
async function handleAuthSuccess(authToken: string) {
try {
setPlexAuthCookie(authToken);
const userData = await fetchPlexUserData(authToken);
if (userData) {
plexUserData.value = userData;
plexUsername.value = userData.username;
isPlexConnected.value = true;
}
const { success, message } = await linkPlexAccount(authToken);
if (success) {
emit("reload");
await fetchPlexLibraries(authToken);
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Authenticated with Plex",
message: message || "Successfully connected your Plex account"
} as IErrorMessage);
} else {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: message || "Could not connect to Plex"
} as IErrorMessage);
}
} catch (error) {
console.error("[PlexSettings] Error in handleAuthSuccess:", error);
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: "An error occurred while connecting to Plex"
} as IErrorMessage);
}
}
function handleAuthError(errorMessage: string) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: errorMessage
} as IErrorMessage);
}
// ----- Unlink flow -----
function confirmUnlink() {
showConfirmModal.value = true;
}
function cancelUnlink() {
showConfirmModal.value = false;
}
async function unauthenticatePlex() {
showConfirmModal.value = false;
loading.value = true;
const response = await unlinkPlexAccount();
if (response?.success) {
localStorage.removeItem("plex_user_data");
document.cookie =
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
plexUserData.value = null;
plexUsername.value = "";
isPlexConnected.value = false;
emit("reload");
}
messages.value.push({
type: response.success
? ErrorMessageTypes.Success
: ErrorMessageTypes.Error,
title: response.success
? "Unlinked Plex account"
: "Something went wrong",
message: response.message
} as IErrorMessage);
loading.value = false;
}
// ----- Library modal -----
function showLibraryDetails(type: string) {
selectedLibrary.value = type;
document.getElementsByTagName("body")[0].classList.add("no-scroll");
showLibraryModal.value = true;
}
function closeLibraryModal() {
showLibraryModal.value = false;
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
selectedLibrary.value = "";
}
// ----- Sync -----
async function syncLibrary() {
syncing.value = true;
const authToken = getCookie("plex_auth_token");
if (!authToken) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "No authentication token found"
} as IErrorMessage);
syncing.value = false;
return;
}
try {
await fetchPlexLibraries(authToken);
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Library synced",
message: "Your Plex library has been successfully synced"
} as IErrorMessage);
} catch (error) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "An error occurred while syncing your library"
} as IErrorMessage);
} finally {
syncing.value = false;
}
}
onMounted(() => {
checkPlexConnection();
loadPlexUserData();
});
onUnmounted(() => {
cleanup();
});
</script>
<style scoped>
.plex-settings {
max-width: 800px;
}
.plex-connected {
display: flex;
flex-direction: column;
gap: 24px;
}
</style>

View File

@@ -0,0 +1,469 @@
<template>
<div class="theme-preferences">
<div class="current-theme">
<div class="theme-display">
<div class="theme-icon" :data-theme="selectedTheme">
<div class="icon-inner"></div>
</div>
<div class="theme-info">
<span class="theme-label">Current Theme</span>
<h3 class="theme-name">{{ currentThemeName }}</h3>
</div>
</div>
</div>
<div class="theme-grid">
<button
v-for="theme in themes"
:key="theme.value"
:class="[
'theme-card',
{ 'theme-card--active': selectedTheme === theme.value }
]"
@click="selectTheme(theme.value)"
>
<div class="theme-card__preview" :data-theme="theme.value">
<div class="preview-circle"></div>
</div>
<span class="theme-card__name">{{ theme.label }}</span>
<div v-if="selectedTheme === theme.value" class="theme-card__badge">
Active
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
interface Theme {
value: string;
label: string;
}
const themes: Theme[] = [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "ocean", label: "Ocean" },
{ value: "nordic", label: "Nordic" },
{ value: "halloween", label: "Halloween" }
];
const selectedTheme = ref("auto");
const currentThemeName = computed(() => {
const theme = themes.find(t => t.value === selectedTheme.value);
return theme ? theme.label : "Auto";
});
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
function selectTheme(theme: string) {
selectedTheme.value = theme;
if (theme === "auto") {
// Use system preference
const systemDark = systemDarkModeEnabled();
document.body.className = systemDark ? "dark" : "light";
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", e => {
if (selectedTheme.value === "auto") {
document.body.className = e.matches ? "dark" : "light";
}
});
} else {
// Manual theme selection
document.body.className = theme;
}
// Save preference to localStorage
localStorage.setItem("theme-preference", theme);
}
onMounted(() => {
// Load saved preference or default to auto
const savedTheme = localStorage.getItem("theme-preference") || "auto";
selectTheme(savedTheme);
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.current-theme {
margin-bottom: 2rem;
padding: 1rem 2rem;
background-color: var(--background-ui);
border-radius: 1rem;
border: 2px solid var(--background-40);
@include mobile-only {
margin-bottom: 1.5rem;
padding: 1.5rem;
border-radius: 0.75rem;
}
}
.theme-display {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
gap: 1.25rem;
}
}
.theme-icon {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@include mobile-only {
width: 70px;
height: 70px;
}
.icon-inner {
width: 100%;
height: 100%;
border-radius: 50%;
}
&[data-theme="light"] .icon-inner {
background: linear-gradient(135deg, #f8f8f8 0%, #e8e8e8 100%);
border: 3px solid #01d277;
}
&[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
border: 3px solid #01d277;
}
&[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027 0%, #2c5364 100%);
border: 3px solid #00d4ff;
}
&[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8 0%, #d8cdb9 100%);
border: 3px solid #3d6e4e;
}
&[data-theme="halloween"] .icon-inner {
background: linear-gradient(135deg, #1a0e2e 0%, #2d1b3d 100%);
border: 3px solid #ff6600;
}
&[data-theme="auto"] .icon-inner {
background: conic-gradient(
from 0deg,
#f8f8f8 0deg 180deg,
#1a1a1a 180deg 360deg
);
border: 3px solid #01d277;
}
}
.theme-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.theme-label {
font-size: 0.85rem;
color: $text-color-70;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
@include mobile-only {
font-size: 0.75rem;
}
}
.theme-name {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
@include mobile-only {
font-size: 1.4rem;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.theme-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
text-align: center;
@include mobile-only {
padding: 0.85rem;
border-radius: 0.5rem;
}
&:hover {
border-color: var(--highlight-color);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&--active {
border-color: var(--highlight-color);
background-color: var(--background-40);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
}
&__preview {
width: 100%;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
position: relative;
border: 1px solid var(--background-40);
@include mobile-only {
height: 100px;
margin-bottom: 0.6rem;
}
.preview-circle {
position: absolute;
border-radius: 50%;
}
}
// Light Theme Preview
&__preview[data-theme="light"] {
background: #f8f8f8;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #01d277;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #ffffff;
border-radius: 4px;
border: 1px solid rgba(8, 28, 36, 0.1);
}
}
// Dark Theme Preview
&__preview[data-theme="dark"] {
background: #111111;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #01d277;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: rgba(6, 7, 8, 1);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
// Ocean Theme Preview
&__preview[data-theme="ocean"] {
background: #0f2027;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #00d4ff;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #203a43;
border-radius: 4px;
border: 1px solid rgba(0, 212, 255, 0.2);
}
}
// Nordic Theme Preview
&__preview[data-theme="nordic"] {
background: #f5f0e8;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #3d6e4e;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #fffef9;
border-radius: 4px;
border: 1px solid rgba(61, 110, 78, 0.2);
}
}
// Halloween Theme Preview
&__preview[data-theme="halloween"] {
background: #1a0e2e;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #ff6600;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #2d1b3d;
border-radius: 4px;
border: 1px solid rgba(255, 102, 0, 0.2);
}
}
// Auto Theme Preview (split)
&__preview[data-theme="auto"] {
background: linear-gradient(
135deg,
#f8f8f8 0%,
#f8f8f8 50%,
#111111 50%,
#111111 100%
);
.preview-circle {
bottom: 8px;
right: 8px;
width: 30px;
height: 30px;
background: #01d277;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
width: calc(50% - 10px);
height: 20px;
background: #ffffff;
border-radius: 4px;
border: 1px solid rgba(8, 28, 36, 0.1);
}
}
&__name {
font-size: 0.9rem;
font-weight: 600;
line-height: 1;
color: var(--text-color);
@include mobile-only {
font-size: 0.85rem;
}
}
&__badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--highlight-color);
color: $white;
border-radius: 1rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
@include mobile-only {
top: 0.4rem;
right: 0.4rem;
padding: 0.2rem 0.4rem;
font-size: 0.6rem;
}
}
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="user-profile">
<div class="profile-card">
<div class="avatar-circle">{{ userInitials }}</div>
<div class="profile-details">
<div class="name-row">
<span class="username">{{ username }}</span>
<span :class="['role-badge', `role-badge--${userRole}`]">{{
userRole
}}</span>
<span
v-if="plexUsername"
class="role-badge role-badge--plex"
:title="`Connected as ${plexUsername}`"
>
<svg
width="12"
height="12"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
/>
</svg>
Plex
</span>
</div>
<span class="member-info">Member since {{ memberSince }}</span>
<span v-if="plexUsername" class="plex-info"
>Connected as {{ plexUsername }}</span
>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
const store = useStore();
const plexUsername = ref<string>("");
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => username.value.slice(0, 2).toUpperCase());
const memberSinceDate = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date;
});
const memberSince = computed(() =>
memberSinceDate.value.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
})
);
const monthsActive = computed(() => {
const now = new Date();
return (
(now.getFullYear() - memberSinceDate.value.getFullYear()) * 12 +
now.getMonth() -
memberSinceDate.value.getMonth()
);
});
// Load Plex username from localStorage
function loadPlexUsername() {
const cachedData = localStorage.getItem("plex_user_data");
if (cachedData) {
try {
const plexData = JSON.parse(cachedData);
plexUsername.value = plexData.username || "";
} catch (error) {
console.error("Error parsing cached Plex data:", error);
}
}
}
onMounted(() => {
loadPlexUsername();
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.user-profile {
@include mobile-only {
width: 100%;
}
}
.profile-card {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.75rem;
gap: 0.75rem;
}
}
.avatar-circle {
width: 55px;
height: 55px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
font-weight: 600;
color: $white;
flex-shrink: 0;
@include mobile-only {
width: 48px;
height: 48px;
font-size: 1.1rem;
}
}
.profile-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.username {
font-size: 1.05rem;
font-weight: 600;
color: $text-color;
line-height: 1.2;
@include mobile-only {
font-size: 0.95rem;
}
}
.member-info {
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.2;
@include mobile-only {
font-size: 0.75rem;
}
}
.plex-info {
font-size: 0.75rem;
color: #cc7b19;
line-height: 1.2;
display: flex;
align-items: center;
gap: 0.3rem;
@include mobile-only {
font-size: 0.7rem;
}
}
.role-badge {
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
font-size: 0.65rem;
text-transform: uppercase;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--admin {
background-color: var(--color-warning);
color: $black;
}
&--user {
background-color: var(--background-40);
color: $text-color;
}
&--plex {
background-color: #cc7b19;
color: $white;
cursor: help;
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -102,9 +102,9 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/elements";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/elements";
h2 {
font-size: 20px;
@@ -115,13 +115,8 @@
margin: 1rem 0;
}
.container {
background-color: $background-color;
}
.no-results {
display: flex;
padding-bottom: 2rem;
justify-content: center;
flex-direction: column;
width: 100%;
@@ -142,7 +137,7 @@
}
@include mobile {
text-align: left;
padding: 0 0.8rem;
}
}

View File

@@ -3,7 +3,7 @@
<thead class="table__header noselect">
<tr>
<th
v-for="column in columns"
v-for="column in visibleColumns"
:key="column"
:class="column === selectedColumn ? 'active' : null"
@click="sortTable(column)"
@@ -22,18 +22,28 @@
class="table__content"
>
<td
class="torrent-info"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.name }}
<div class="torrent-title">{{ torrent.name }}</div>
<div v-if="isMobile" class="torrent-meta">
<span class="meta-item">{{ torrent.size }}</span>
<span class="meta-separator"></span>
<span class="meta-item">{{ torrent.seed }} seeders</span>
</div>
</td>
<td
v-if="!isMobile"
class="torrent-seed"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.seed }}
</td>
<td
v-if="!isMobile"
class="torrent-size"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
@@ -52,7 +62,7 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconMagnet from "@/icons/IconMagnet.vue";
import type { Ref } from "vue";
import { sortableSize } from "../../utils";
@@ -70,12 +80,29 @@
const emit = defineEmits<Emit>();
const columns: string[] = ["name", "seed", "size", "add"];
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const visibleColumns = computed(() =>
isMobile.value ? ["name", "add"] : columns
);
const torrents: Ref<ITorrent[]> = ref(props.torrents);
const direction: Ref<boolean> = ref(false);
const selectedColumn: Ref<string> = ref(columns[0]);
const prevCol: Ref<string> = ref("");
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
function expand(event: MouseEvent, text: string) {
const elementClicked = event.target as HTMLElement;
const tableRow = elementClicked.parentElement;
@@ -100,7 +127,9 @@
expandedCol.dataset[scopedStyleDataVariable] = "";
expandedRow.className = "expanded";
expandedCol.innerText = text;
expandedCol.colSpan = 4;
// Colspan: 2 on mobile (name + add), 4 on desktop (name + seed + size + add)
expandedCol.colSpan = isMobile.value ? 2 : 4;
expandedRow.appendChild(expandedCol);
tableRow.insertAdjacentElement("afterend", expandedRow);
@@ -155,24 +184,31 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/elements";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/elements";
table {
border-spacing: 0;
margin-top: 0.5rem;
width: 100%;
// border-collapse: collapse;
max-width: 100%;
border-radius: 0.5rem;
overflow: hidden;
table-layout: fixed;
@include mobile {
table-layout: auto;
}
}
th,
td {
border: 0.5px solid var(--background-color-40);
overflow: hidden;
text-overflow: ellipsis;
@include mobile {
white-space: nowrap;
padding: 0;
}
}
@@ -185,8 +221,7 @@
text-transform: uppercase;
cursor: pointer;
background-color: var(--table-background-color);
// background-color: black;
// color: var(--color-green);
background-color: var(--highlight-color);
letter-spacing: 0.8px;
font-size: 1rem;
@@ -196,31 +231,69 @@
}
tbody {
// first column
tr td:first-of-type {
// first column - torrent info
.torrent-info {
position: relative;
padding: 0 0.3rem;
padding: 0.5rem 0.6rem;
cursor: default;
word-break: break-all;
word-break: break-word;
border-left: 1px solid var(--table-background-color);
@include mobile {
max-width: 40vw;
overflow-x: hidden;
width: 100%;
padding: 0.75rem 0.5rem;
}
.torrent-title {
font-weight: 500;
margin-bottom: 0.25rem;
line-height: 1.3;
word-break: break-word;
overflow-wrap: break-word;
@include mobile {
font-size: 0.95rem;
}
}
.torrent-meta {
font-size: 0.85rem;
color: var(--text-color-60);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
.meta-item {
white-space: nowrap;
}
.meta-separator {
color: var(--text-color-40);
}
}
}
// all columns except first
tr td:not(td:first-of-type) {
// seed and size columns (desktop only)
.torrent-seed,
.torrent-size {
text-align: center;
white-space: nowrap;
padding: 0.5rem;
}
// last column
// last column - action
tr td:last-of-type {
vertical-align: middle;
cursor: pointer;
border-right: 1px solid var(--table-background-color);
width: 60px;
text-align: center;
@include mobile {
width: 50px;
}
svg {
width: 21px;
@@ -228,10 +301,17 @@
margin: auto;
padding: 0.3rem 0;
fill: var(--text-color);
@include mobile {
width: 18px;
}
}
}
// alternate background color per row
tr {
background-color: var(--background-color);
}
tr:nth-child(even) {
background-color: var(--background-70);
}

View File

@@ -1,36 +1,40 @@
<template>
<div>
<torrent-search-results
:query="query"
:tmdb-id="tmdbId"
:class="{ truncated: truncated }"
><div
v-if="truncated"
class="load-more"
tabindex="0"
role="button"
@click="truncated = false"
@keydown.enter="truncated = false"
>
<icon-arrow-down />
</div>
</torrent-search-results>
<div class="search-results">
<torrent-search-results
:query="query"
:tmdb-id="tmdbId"
:class="{ truncated: _truncated }"
><div
v-if="_truncated"
class="load-more"
tabindex="0"
role="button"
@click="truncated = false"
@keydown.enter="truncated = false"
>
<icon-arrow-down />
</div>
</torrent-search-results>
</div>
<div class="edit-query-btn-container">
<seasonedButton @click="openInTorrentPage"
>View on torrent page</seasonedButton
>
<a :href="`/torrents?query=${encodeURIComponent(props.query)}`">
<button>
<span class="text">View on torrent page</span
><span class="icon"><icon-arrow-down /></span>
</button>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ref, defineProps, computed } from "vue";
import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconArrowDown from "@/icons/IconArrowDown.vue";
import type { Ref } from "vue";
import store from "../../store";
interface Props {
query: string;
@@ -38,18 +42,13 @@
}
const props = defineProps<Props>();
const router = useRouter();
const truncated: Ref<boolean> = ref(true);
function openInTorrentPage() {
if (!props.query?.length) {
router.push("/torrents");
return;
}
router.push({ path: "/torrents", query: { query: props.query } });
}
const _truncated = computed(() => {
const val = store.getters["torrentModule/resultCount"];
if (val > 10 && truncated.value) return true;
return false;
});
</script>
<style lang="scss" scoped>
@@ -75,14 +74,68 @@
);
}
svg {
height: 30px;
fill: var(--text-color);
.search-results {
svg {
height: 30px;
fill: var(--text-color);
}
}
.edit-query-btn-container {
display: flex;
justify-content: center;
padding: 1rem;
padding-bottom: 2rem;
a button {
--height: 45px;
transition: all 0.8s ease !important;
position: relative;
font-size: 1rem;
line-height: 1.5;
letter-spacing: 0.2px;
font-family: Arial, Helvetica, sans-serif;
font-weight: 600;
color: var(--highlight-bg, var(--background-color));
background-color: var(--text-color);
min-height: var(--height);
padding: 0rem 1.5rem;
margin: 0;
border: 2px solid var(--text-color);
border-radius: calc(var(--height) / 2);
cursor: pointer;
outline: none;
overflow-x: hidden;
&:hover {
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
padding: 0 2rem;
span.text {
margin-left: -0.5rem;
margin-right: 0.5rem;
}
span.icon {
right: 1rem;
}
}
span.icon {
--size: 1rem;
display: block;
transform: rotate(-90deg);
transform-origin: top left;
stroke: var(--text-color);
fill: var(--text-color);
height: var(--size);
width: var(--size);
margin-top: -4px;
position: absolute;
right: 1rem;
right: -1rem;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,839 @@
<template>
<transition name="fade">
<div v-if="isOpen" class="command-palette-overlay" @click="close">
<div class="command-palette" @click.stop>
<div class="command-palette__search">
<input
v-if="!parameterMode"
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="Search routes..."
class="command-palette__input"
@keydown.down.prevent="selectNext"
@keydown.up.prevent="selectPrevious"
@keydown.enter.prevent="navigateToSelected"
@keydown.esc.prevent="close"
@keydown="handleInputKeydown"
/>
<input
v-else
ref="parameterInput"
v-model="parameterValue"
type="text"
:placeholder="`Enter ${parameterName}...`"
class="command-palette__input command-palette__input--parameter"
@keydown.enter.prevent="confirmParameter"
@keydown.esc.prevent="cancelParameter"
/>
</div>
<div v-if="!parameterMode" class="command-palette__results">
<div
v-for="(route, index) in filteredRoutes"
:key="route.path"
:class="[
'command-palette__item',
{ 'command-palette__item--selected': index === selectedIndex }
]"
@click="navigateTo(route)"
@mouseenter="selectedIndex = index"
>
<div class="command-palette__item-left">
<div class="command-palette__item-icon">
<component :is="getRouteIcon(route.name)" />
</div>
<div class="command-palette__item-content">
<div class="command-palette__item-title">
<span class="command-palette__item-name">{{
formatRouteName(route.name)
}}</span>
<span class="command-palette__item-path">
{{ route.path }}
<span
v-if="routeRequiresInput(route)"
class="command-palette__item-param-hint"
>
(requires {{ getInputParameterName(route) }})
</span>
</span>
</div>
<span class="command-palette__item-description">{{
getRouteDescription(route.name)
}}</span>
</div>
</div>
<div class="command-palette__item-right">
<span
v-if="route.meta?.requiresAuth"
class="command-palette__item-badge command-palette__item-badge--auth"
>
🔒 Auth
</span>
<span
v-if="route.meta?.requiresPlexAccount"
class="command-palette__item-badge command-palette__item-badge--plex"
>
Plex
</span>
<span v-if="index < 9" class="command-palette__item-shortcut">
{{ index + 1 }}
</span>
</div>
</div>
<div
v-if="filteredRoutes.length === 0 && contentResults.length === 0"
class="command-palette__empty"
>
<span v-if="isSearchingContent">Searching content...</span>
<span v-else-if="searchDisabled"
>Search temporarily disabled due to errors</span
>
<span v-else>No routes or content found</span>
</div>
<div
v-if="filteredRoutes.length === 0 && contentResults.length > 0"
class="command-palette__content-results"
>
<div class="command-palette__content-header">Movies & Shows</div>
<div
v-for="(result, index) in contentResults"
:key="result.id"
:class="[
'command-palette__item',
{ 'command-palette__item--selected': index === selectedIndex }
]"
@click="openContent(result)"
@mouseenter="selectedIndex = index"
>
<div class="command-palette__item-left">
<div class="command-palette__item-icon">
<component
:is="result.type === 'movie' ? IconMovie : IconShow"
/>
</div>
<div class="command-palette__item-content">
<div class="command-palette__item-title">
<span class="command-palette__item-name">{{
result.title
}}</span>
</div>
<span class="command-palette__item-description">
{{ result.type === "movie" ? "Movie" : "TV Show" }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import type { RouteRecordNormalized } from "vue-router";
import type { Component } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconInbox from "@/icons/IconInbox.vue";
import IconSearch from "@/icons/IconSearch.vue";
import IconEdit from "@/icons/IconEdit.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconKey from "@/icons/IconKey.vue";
import IconMagnet from "@/icons/IconMagnet.vue";
import IconProfileLock from "@/icons/IconProfileLock.vue";
import IconShow from "@/icons/IconShow.vue";
import { elasticSearchMoviesAndShows } from "@/api";
import type { IAutocompleteResult } from "@/interfaces/IAutocompleteSearch";
import { trackCommand, getCommandScore } from "@/utils/commandTracking";
const router = useRouter();
const store = useStore();
const isOpen = ref(false);
const searchQuery = ref("");
const selectedIndex = ref(0);
const searchInput = ref<HTMLInputElement | null>(null);
const parameterInput = ref<HTMLInputElement | null>(null);
const parameterMode = ref(false);
const parameterName = ref("");
const parameterValue = ref("");
const pendingRoute = ref<RouteRecordNormalized | null>(null);
const contentResults = ref<IAutocompleteResult[]>([]);
const isSearchingContent = ref(false);
const searchDisabled = ref(false);
const searchErrorCount = ref(0);
const lastSearchTime = ref(0);
const SEARCH_COOLDOWN = 500; // ms between searches
const MAX_ERRORS = 3; // Disable after 3 errors
const routeMetadata: Record<
string,
{
icon: Component;
description: string;
requiresInput?: boolean;
inputParamName?: string;
}
> = {
home: { icon: IconMovie, description: "Browse movies and TV shows" },
activity: { icon: IconActivity, description: "View Plex server activity" },
profile: { icon: IconProfile, description: "Manage your profile" },
"requests-list": null,
list: { icon: IconInbox, description: "Browse custom lists" },
search: {
icon: IconSearch,
description: "Search for content",
requiresInput: true,
inputParamName: "query"
},
register: { icon: IconEdit, description: "Create a new account" },
settings: { icon: IconSettings, description: "Configure your preferences" },
signin: { icon: IconKey, description: "Sign in to your account" },
torrents: { icon: IconMagnet, description: "Manage torrents" },
"password-gen": {
icon: IconKey,
description: "Generate secure passwords"
},
admin: { icon: IconProfileLock, description: "Admin dashboard" }
};
const routes = computed(() => {
return router.getRoutes().filter(route => {
return (
routeMetadata[route?.name?.toString() ?? ""] &&
route.name &&
route.name !== "NotFound"
);
});
});
const filteredRoutes = computed(() => {
let filtered: RouteRecordNormalized[];
if (!searchQuery.value) {
filtered = routes.value;
} else {
const query = searchQuery.value.toLowerCase();
filtered = routes.value.filter(route => {
const name = String(route.name).toLowerCase();
const path = route.path.toLowerCase();
return name.includes(query) || path.includes(query);
});
}
// Sort by command score (most used + recent first)
return filtered.sort((a, b) => {
const scoreA = getCommandScore(String(a.name));
const scoreB = getCommandScore(String(b.name));
return scoreB - scoreA;
});
});
function formatRouteName(name: string | symbol | undefined): string {
if (!name) return "";
const str = String(name);
return str
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function getRouteIcon(name: string | symbol | undefined): Component {
if (!name) return IconMovie;
const routeName = String(name);
return routeMetadata[routeName]?.icon || IconMovie;
}
function getRouteDescription(name: string | symbol | undefined): string {
if (!name) return "";
const routeName = String(name);
return routeMetadata[routeName]?.description || "";
}
function open() {
isOpen.value = true;
searchQuery.value = "";
selectedIndex.value = 0;
// Reset search state when opening
searchErrorCount.value = 0;
searchDisabled.value = false;
}
function close() {
isOpen.value = false;
searchQuery.value = "";
selectedIndex.value = 0;
parameterMode.value = false;
parameterName.value = "";
parameterValue.value = "";
pendingRoute.value = null;
}
function scrollSelectedIntoView() {
nextTick(() => {
const selectedElement = document.querySelector(
".command-palette__item--selected"
);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest"
});
}
});
}
function selectNext() {
const maxIndex = Math.max(
filteredRoutes.value.length - 1,
contentResults.value.length - 1
);
if (selectedIndex.value < maxIndex) {
selectedIndex.value++;
scrollSelectedIntoView();
}
}
function selectPrevious() {
if (selectedIndex.value > 0) {
selectedIndex.value--;
scrollSelectedIntoView();
}
}
function navigateToSelected() {
// Check if we have route results
if (filteredRoutes.value.length > 0) {
const route = filteredRoutes.value[selectedIndex.value];
if (route) {
navigateTo(route);
return;
}
}
// Check if we have content results
if (contentResults.value.length > 0) {
const result = contentResults.value[selectedIndex.value];
if (result) {
openContent(result);
return;
}
}
}
function hasParameter(path: string): boolean {
return path.includes(":");
}
function extractParameterName(path: string): string {
const match = path.match(/:([^/]+)/);
return match ? match[1] : "";
}
function routeRequiresInput(route: RouteRecordNormalized): boolean {
const routeName = String(route.name);
return routeMetadata[routeName]?.requiresInput || hasParameter(route.path);
}
function getInputParameterName(route: RouteRecordNormalized): string {
const routeName = String(route.name);
if (routeMetadata[routeName]?.inputParamName) {
return routeMetadata[routeName].inputParamName!;
}
return extractParameterName(route.path);
}
function navigateTo(route: RouteRecordNormalized) {
if (routeRequiresInput(route)) {
// Enter parameter mode
parameterMode.value = true;
parameterName.value = getInputParameterName(route);
parameterValue.value = "";
pendingRoute.value = route;
setTimeout(() => {
parameterInput.value?.focus();
}, 50);
} else {
// Track the command usage
trackCommand(String(route.name), "route", { routePath: route.path });
router.push(route.path);
close();
}
}
function confirmParameter() {
if (!pendingRoute.value || !parameterValue.value.trim()) return;
const routeName = String(pendingRoute.value.name);
const metadata = routeMetadata[routeName];
// Track the command usage
trackCommand(routeName, "route", { routePath: pendingRoute.value.path });
// Check if this route uses query parameters instead of path parameters
if (metadata?.inputParamName) {
router.push({
path: pendingRoute.value.path,
query: { [metadata.inputParamName]: parameterValue.value.trim() }
});
} else {
// Traditional path parameter replacement
const path = pendingRoute.value.path.replace(
/:([^/]+)/,
parameterValue.value.trim()
);
router.push(path);
}
close();
}
function cancelParameter() {
parameterMode.value = false;
parameterName.value = "";
parameterValue.value = "";
pendingRoute.value = null;
setTimeout(() => {
searchInput.value?.focus();
}, 50);
}
async function searchContent() {
// Prevent searching if already searching, disabled, or on cooldown
if (isSearchingContent.value || searchDisabled.value) return;
const now = Date.now();
if (now - lastSearchTime.value < SEARCH_COOLDOWN) return;
lastSearchTime.value = now;
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// Don't search if elastic is not configured
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
contentResults.value = [];
return;
}
isSearchingContent.value = true;
try {
const response = await elasticSearchMoviesAndShows(searchQuery.value, 10);
const results: IAutocompleteResult[] = response.hits.hits.map(
(item: any) => ({
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
type: item._source.type === "movie" ? "movie" : "show"
})
);
// Sort content results by command score (most used + recent first)
const sortedResults = results.sort((a, b) => {
const scoreA = getCommandScore(`${a.type}:${a.id}`);
const scoreB = getCommandScore(`${b.type}:${b.id}`);
return scoreB - scoreA;
});
contentResults.value = sortedResults;
// Reset error count on success
searchErrorCount.value = 0;
} catch (error) {
console.error("Search failed:", error);
contentResults.value = [];
// Increment error count and disable if threshold reached
searchErrorCount.value++;
if (searchErrorCount.value >= MAX_ERRORS) {
searchDisabled.value = true;
console.warn(
`Content search disabled after ${MAX_ERRORS} consecutive errors`
);
}
} finally {
isSearchingContent.value = false;
}
}
function openContent(result: IAutocompleteResult) {
// Track content opening with unique ID
const contentId = `${result.type}:${result.id}`;
trackCommand(contentId, "content");
store.dispatch("popup/open", {
id: result.id,
type: result.type
});
close();
}
function handleInputKeydown(event: KeyboardEvent) {
// Check for number keys 1-9 to select routes or content
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= 9) {
const index = num - 1;
// Try routes first
if (index < filteredRoutes.value.length) {
event.preventDefault();
navigateTo(filteredRoutes.value[index]);
return;
}
// Try content results
if (index < contentResults.value.length) {
event.preventDefault();
openContent(contentResults.value[index]);
return;
}
}
}
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
if (isOpen.value) {
close();
} else {
open();
}
}
if (event.key === "Escape" && isOpen.value) {
event.preventDefault();
close();
}
}
watch(isOpen, newValue => {
if (newValue) {
document.body.style.overflow = "hidden";
setTimeout(() => {
searchInput.value?.focus();
}, 50);
} else {
document.body.style.overflow = "";
}
});
let searchTimeout: NodeJS.Timeout | null = null;
watch(searchQuery, () => {
selectedIndex.value = 0;
// Don't clear content results immediately - let debounce handle it
});
// Trigger content search when no routes match (with debouncing)
watch(filteredRoutes, newRoutes => {
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (newRoutes.length === 0 && searchQuery.value.length > 0) {
// Debounce search to avoid clearing results while typing fast
searchTimeout = setTimeout(() => {
searchContent();
}, 300);
} else if (newRoutes.length > 0) {
// Clear content results when routes are found
contentResults.value = [];
}
});
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown);
});
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
@import "scss/variables.scss";
@import "scss/media-queries.scss";
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.command-palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 9999;
padding-top: 15vh;
@include mobile {
padding-top: 10vh;
}
}
.command-palette {
background: var(--background-color-secondary);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 640px;
max-height: 60vh;
display: flex;
flex-direction: column;
overflow: hidden;
@include mobile {
width: 95%;
max-height: 70vh;
}
}
.command-palette__search {
padding: 1rem;
border-bottom: 1px solid var(--text-color-10);
}
.command-palette__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1.1rem;
border: none;
background: var(--background-ui);
color: var(--text-color);
border-radius: 8px;
outline: none;
font-family: inherit;
&::placeholder {
color: var(--text-color-50);
}
&--parameter {
background: var(--color-success);
color: var(--color-success-text);
font-weight: 500;
&::placeholder {
color: var(--color-success-text);
opacity: 0.8;
}
}
@include mobile {
font-size: 1rem;
padding: 0.625rem 0.875rem;
}
}
.command-palette__results {
overflow-y: auto;
max-height: 50vh;
padding: 0.5rem;
@include mobile {
max-height: 60vh;
}
}
.command-palette__item {
padding: 1rem;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.15s ease;
gap: 1rem;
&:hover,
&--selected {
background: var(--background-ui);
}
@include mobile {
padding: 0.875rem;
gap: 0.75rem;
}
}
.command-palette__item-left {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
min-width: 0;
@include mobile {
gap: 0.625rem;
}
}
.command-palette__item-icon {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color-70);
svg {
width: 100%;
height: 100%;
fill: currentColor;
}
@include mobile {
width: 1.25rem;
height: 1.25rem;
}
}
.command-palette__item-content {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.command-palette__item-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.command-palette__item-name {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
@include mobile {
font-size: 0.95rem;
}
}
.command-palette__item-path {
font-size: 0.8rem;
color: var(--text-color-50);
font-weight: 400;
@include mobile {
font-size: 0.75rem;
}
}
.command-palette__item-param-hint {
font-size: 0.75rem;
color: var(--text-color-70);
font-style: italic;
margin-left: 0.25rem;
@include mobile {
font-size: 0.7rem;
}
}
.command-palette__item-description {
font-size: 0.875rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile {
font-size: 0.8rem;
}
}
.command-palette__item-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
@include mobile {
gap: 0.375rem;
}
}
.command-palette__item-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
font-weight: 500;
&--auth {
background: var(--color-warning);
color: var(--text-color);
}
&--plex {
background: var(--color-success);
color: var(--color-success-text);
}
@include mobile {
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
}
}
.command-palette__item-shortcut {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--background-ui);
color: var(--text-color-70);
border: 1px solid var(--text-color-10);
border-radius: 4px;
font-weight: 600;
min-width: 1.5rem;
text-align: center;
@include mobile {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
min-width: 1.25rem;
}
}
.command-palette__empty {
padding: 2rem;
text-align: center;
color: var(--text-color-50);
font-size: 0.95rem;
}
.command-palette__content-results {
padding: 0.5rem;
}
.command-palette__content-header {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-50);
padding: 0.5rem 1rem;
margin-bottom: 0.25rem;
}
</style>

View File

@@ -23,7 +23,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.nav__hamburger {
display: block;

View File

@@ -22,7 +22,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "scss/variables";
.loader {
display: flex;

View File

@@ -20,5 +20,5 @@
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "scss/loading-placeholder";
</style>

View File

@@ -23,8 +23,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
button {
display: inline-block;
@@ -37,12 +37,14 @@
min-height: 45px;
padding: 5px 10px 4px 10px;
margin: 0;
margin-right: 0.3rem;
color: $text-color;
background: $background-color-secondary;
cursor: pointer;
outline: none;
transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease;
transition:
background 0.5s ease,
color 0.5s ease,
border-color 0.5s ease;
@include desktop {
font-size: 0.8rem;

View File

@@ -74,14 +74,13 @@
}
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.group {
display: flex;
width: 100%;
position: relative;
max-width: 35rem;
border: 1px solid var(--text-color-50);
background-color: var(--background-color-secondary);

View File

@@ -64,8 +64,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.fade-active {
transition: opacity 0.4s;

View File

@@ -33,7 +33,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "scss/variables";
$background: $background-ui;
$background-selected: $background-color-secondary;

View File

@@ -0,0 +1,200 @@
import { ref } from "vue";
// Shared constants - generated once and reused
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
export const APP_NAME = window.location.hostname;
export function usePlexApi() {
const plexServerUrl = ref("");
// Fetch Plex user data
async function fetchPlexUserData(authToken: string) {
try {
const response = await fetch("https://plex.tv/api/v2/user", {
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Product": APP_NAME,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
"X-Plex-Token": authToken
}
});
if (!response.ok) {
throw new Error("Failed to fetch Plex user info");
}
const data = await response.json();
// Convert Unix timestamp to ISO date string if needed
let joinedDate = null;
if (data.joinedAt) {
if (typeof data.joinedAt === "number") {
joinedDate = new Date(data.joinedAt * 1000).toISOString();
} else {
joinedDate = data.joinedAt;
}
}
const userData = {
id: data.id,
uuid: data.uuid,
username: data.username || data.title || "Plex User",
email: data.email,
thumb: data.thumb,
joined_at: joinedDate,
two_factor_enabled: data.twoFactorEnabled || false,
experimental_features: data.experimentalFeatures || false,
subscription: {
active: data.subscription?.active,
plan: data.subscription?.plan,
features: data.subscription?.features
},
profile: {
auto_select_audio: data.profile?.autoSelectAudio,
default_audio_language: data.profile?.defaultAudioLanguage,
default_subtitle_language: data.profile?.defaultSubtitleLanguage
},
entitlements: data.entitlements || [],
roles: data.roles || [],
created_at: new Date().toISOString()
};
localStorage.setItem("plex_user_data", JSON.stringify(userData));
return userData;
} catch (error) {
console.error("[PlexAPI] Error fetching Plex user data:", error);
return null;
}
}
// Fetch Plex servers
async function fetchPlexServers(authToken: string) {
try {
const response = await fetch(
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1",
{
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
}
);
if (!response.ok) {
throw new Error("Failed to fetch Plex servers");
}
const servers = await response.json();
const ownedServer = servers.find(
(s: any) => s.owned && s.provides === "server"
);
if (ownedServer) {
const connection =
ownedServer.connections?.find((c: any) => c.local === false) ||
ownedServer.connections?.[0];
if (connection) {
plexServerUrl.value = connection.uri;
}
return {
name: ownedServer.name,
url: plexServerUrl.value,
machineIdentifier: ownedServer.clientIdentifier
};
}
return null;
} catch (error) {
console.error("[PlexAPI] Error fetching Plex servers:", error);
return null;
}
}
// Fetch library sections
async function fetchLibrarySections(authToken: string, serverUrl: string) {
if (!serverUrl) return [];
try {
const response = await fetch(`${serverUrl}/library/sections`, {
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken
}
});
if (!response.ok) {
throw new Error("Failed to fetch library sections");
}
const data = await response.json();
return data.MediaContainer?.Directory || [];
} catch (error) {
console.error("[PlexAPI] Error fetching library sections:", error);
return [];
}
}
// Fetch library details
async function fetchLibraryDetails(
authToken: string,
serverUrl: string,
sectionKey: string
) {
if (!serverUrl) return null;
try {
// Fetch all items
const allResponse = await fetch(
`${serverUrl}/library/sections/${sectionKey}/all`,
{
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken
}
}
);
if (!allResponse.ok) throw new Error("Failed to fetch all items");
const allData = await allResponse.json();
// Fetch recently added
const size = 20;
const recentResponse = await fetch(
`${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`,
{
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken
}
}
);
if (!recentResponse.ok) throw new Error("Failed to fetch recently added");
const recentData = await recentResponse.json();
return {
all: allData,
recent: recentData,
metadata: allData.MediaContainer?.Metadata || [],
recentMetadata: recentData.MediaContainer?.Metadata || []
};
} catch (error) {
console.error("[PlexAPI] Error fetching library details:", error);
return null;
}
}
return {
plexServerUrl,
fetchPlexUserData,
fetchPlexServers,
fetchLibrarySections,
fetchLibraryDetails
};
}

View File

@@ -0,0 +1,197 @@
import { ref } from "vue";
import { CLIENT_IDENTIFIER, APP_NAME } from "./usePlexApi";
export function usePlexAuth() {
const loading = ref(false);
const plexPopup = ref<Window | null>(null);
const pollInterval = ref<number | null>(null);
// Generate a PIN for Plex OAuth
async function generatePlexPin() {
try {
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
method: "POST",
headers: {
accept: "application/json",
"X-Plex-Product": APP_NAME,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
});
if (!response.ok) throw new Error("Failed to generate PIN");
const data = await response.json();
return { id: data.id, code: data.code };
} catch (error) {
console.error("[PlexAuth] Error generating PIN:", error);
return null;
}
}
// Check PIN status
async function checkPin(pinId: number, pinCode: string) {
try {
const response = await fetch(
`https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
{
headers: {
accept: "application/json",
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
}
);
if (!response.ok) return null;
const data = await response.json();
return data.authToken;
} catch (error) {
console.error("[PlexAuth] Error checking PIN:", error);
return null;
}
}
// Construct auth URL
function constructAuthUrl(pinCode: string) {
const params = new URLSearchParams({
clientID: CLIENT_IDENTIFIER,
code: pinCode,
"context[device][product]": APP_NAME
});
return `https://app.plex.tv/auth#?${params.toString()}`;
}
// Start polling for PIN
function startPolling(
pinId: number,
pinCode: string,
onSuccess: (token: string) => void
) {
pollInterval.value = window.setInterval(async () => {
const authToken = await checkPin(pinId, pinCode);
if (authToken) {
stopPolling();
if (plexPopup.value && !plexPopup.value.closed) {
plexPopup.value.close();
}
onSuccess(authToken);
}
}, 1000);
}
// Stop polling
function stopPolling() {
if (pollInterval.value) {
clearInterval(pollInterval.value);
pollInterval.value = null;
}
}
// Set cookie
function setPlexAuthCookie(authToken: string) {
const expires = new Date();
expires.setDate(expires.getDate() + 30);
document.cookie = `plex_auth_token=${authToken}; path=/; expires=${expires.toUTCString()}; SameSite=Strict`;
}
// Get cookie
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
return null;
}
// Open authentication popup
async function openAuthPopup(
onSuccess: (token: string) => void,
onError: (msg: string) => void
) {
loading.value = true;
const width = 600;
const height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
plexPopup.value = window.open(
"about:blank",
"PlexAuth",
`width=${width},height=${height},left=${left},top=${top}`
);
if (!plexPopup.value) {
onError("Please allow popups for this site to authenticate with Plex");
loading.value = false;
return;
}
// Add loading screen
if (plexPopup.value.document) {
plexPopup.value.document.write(`
<html>
<head>
<title>Connecting to Plex...</title>
<style>
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center;
height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1c3a13; color: #fcfcf7; }
.spinner { border: 4px solid rgba(252, 252, 247, 0.3); border-top: 4px solid #fcfcf7;
border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite;
margin: 0 auto 20px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body><div class="loader"><div class="spinner"></div><p>Connecting to Plex...</p></div></body>
</html>
`);
}
const pin = await generatePlexPin();
if (!pin) {
if (plexPopup.value && !plexPopup.value.closed) plexPopup.value.close();
onError("Could not generate Plex authentication PIN");
loading.value = false;
return;
}
const authUrl = constructAuthUrl(pin.code);
if (plexPopup.value && !plexPopup.value.closed) {
plexPopup.value.location.href = authUrl;
} else {
onError("Authentication window was closed");
loading.value = false;
return;
}
startPolling(pin.id, pin.code, onSuccess);
// Check if popup closed
const popupChecker = setInterval(() => {
if (plexPopup.value && plexPopup.value.closed) {
clearInterval(popupChecker);
stopPolling();
if (loading.value) {
loading.value = false;
onError("Plex authentication window was closed");
}
}
}, 500);
}
// Cleanup
function cleanup() {
stopPolling();
if (plexPopup.value && !plexPopup.value.closed) {
plexPopup.value.close();
}
}
return {
loading,
setPlexAuthCookie,
getCookie,
openAuthPopup,
cleanup
};
}

View File

@@ -0,0 +1,169 @@
import {
processLibraryItem,
calculateGenreStats,
calculateDuration
} from "@/utils/plexHelpers";
export function usePlexLibraries() {
async function loadLibraries(
sections: any[],
authToken: string,
serverUrl: string,
machineIdentifier: string,
username: string,
fetchLibraryDetailsFn: any
) {
// Reset stats
const stats = { movies: 0, shows: 0, music: 0, watchtime: 0 };
const details: any = {
movies: {
total: 0,
recentlyAdded: [],
genres: [],
totalDuration: "0 hours"
},
shows: {
total: 0,
recentlyAdded: [],
genres: [],
totalEpisodes: 0,
totalDuration: "0 hours"
},
music: { total: 0, recentlyAdded: [], genres: [], totalTracks: 0 }
};
try {
for (const section of sections) {
const { type } = section;
const { key } = section;
if (type === "movie") {
await processLibrarySection(
authToken,
serverUrl,
machineIdentifier,
key,
"movies",
stats,
details,
fetchLibraryDetailsFn
);
} else if (type === "show") {
await processLibrarySection(
authToken,
serverUrl,
machineIdentifier,
key,
"shows",
stats,
details,
fetchLibraryDetailsFn
);
} else if (type === "artist") {
await processLibrarySection(
authToken,
serverUrl,
machineIdentifier,
key,
"music",
stats,
details,
fetchLibraryDetailsFn
);
}
}
// Calculate watchtime from Tautulli if username provided
if (username) {
try {
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent(
username
)}&length=8000`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
const history = data.response?.data?.data || [];
const totalMs = history.reduce(
(sum: number, item: any) => sum + (item.duration || 0) * 1000,
0
);
stats.watchtime = Math.round(totalMs / (1000 * 60 * 60));
}
} catch (error) {
console.error("[PlexLibraries] Error fetching watchtime:", error);
}
}
return { stats, details };
} catch (error) {
console.error("[PlexLibraries] Error loading libraries:", error);
throw error;
}
}
async function processLibrarySection(
authToken: string,
serverUrl: string,
machineIdentifier: string,
sectionKey: string,
libraryType: string,
stats: any,
details: any,
fetchLibraryDetailsFn: any
) {
try {
const data = await fetchLibraryDetailsFn(
authToken,
serverUrl,
sectionKey
);
if (!data) return;
const totalCount = data.all.MediaContainer?.size || 0;
// Update stats
if (libraryType === "movies") {
stats.movies += totalCount;
} else if (libraryType === "shows") {
stats.shows += totalCount;
} else if (libraryType === "music") {
stats.music += totalCount;
}
// Process recently added items
const recentItems = data.recentMetadata.map((item: any) =>
processLibraryItem(
item,
libraryType,
authToken,
serverUrl,
machineIdentifier
)
);
// Calculate stats
const genres = calculateGenreStats(data.metadata);
const durations = calculateDuration(data.metadata, libraryType);
// Update library details
details[libraryType] = {
total: totalCount,
recentlyAdded: recentItems,
genres,
totalDuration: durations.totalDuration,
...(libraryType === "shows" && {
totalEpisodes: durations.totalEpisodes
}),
...(libraryType === "music" && { totalTracks: durations.totalTracks })
};
} catch (error) {
console.error(`[PlexLibraries] Error processing ${libraryType}:`, error);
}
}
return {
loadLibraries
};
}

View File

@@ -0,0 +1,741 @@
// Composable for fetching random words for password generation
// Uses Random Word API with fallback to EFF Diceware word list
export function useRandomWords() {
// EFF Diceware short word list (optimized for memorability)
// Source: https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases
const FALLBACK_WORDS = [
"able",
"acid",
"aged",
"also",
"area",
"army",
"away",
"baby",
"back",
"ball",
"band",
"bank",
"base",
"bath",
"bear",
"beat",
"been",
"beer",
"bell",
"belt",
"best",
"bike",
"bill",
"bird",
"blow",
"blue",
"boat",
"body",
"bold",
"bolt",
"bomb",
"bond",
"bone",
"book",
"boom",
"born",
"boss",
"both",
"bowl",
"bulk",
"burn",
"bush",
"busy",
"cage",
"cake",
"call",
"calm",
"came",
"camp",
"card",
"care",
"cart",
"case",
"cash",
"cast",
"cell",
"chat",
"chip",
"city",
"clad",
"clay",
"clip",
"club",
"clue",
"coal",
"coat",
"code",
"coil",
"coin",
"cold",
"come",
"cook",
"cool",
"cope",
"copy",
"cord",
"core",
"cork",
"cost",
"crab",
"crew",
"crop",
"crow",
"curl",
"cute",
"damp",
"dare",
"dark",
"dash",
"data",
"date",
"dawn",
"days",
"dead",
"deaf",
"deal",
"dean",
"dear",
"debt",
"deck",
"deed",
"deep",
"deer",
"demo",
"deny",
"desk",
"dial",
"dice",
"died",
"diet",
"disc",
"dish",
"disk",
"dock",
"does",
"dome",
"done",
"doom",
"door",
"dose",
"down",
"drag",
"draw",
"drew",
"drip",
"drop",
"drug",
"drum",
"dual",
"duck",
"dull",
"dumb",
"dump",
"dune",
"dunk",
"dust",
"duty",
"each",
"earl",
"earn",
"ease",
"east",
"easy",
"edge",
"edit",
"else",
"even",
"ever",
"evil",
"exam",
"exit",
"face",
"fact",
"fade",
"fail",
"fair",
"fake",
"fall",
"fame",
"farm",
"fast",
"fate",
"fear",
"feed",
"feel",
"feet",
"fell",
"felt",
"fern",
"file",
"fill",
"film",
"find",
"fine",
"fire",
"firm",
"fish",
"fist",
"five",
"flag",
"flat",
"fled",
"flew",
"flip",
"flow",
"folk",
"fond",
"food",
"fool",
"foot",
"ford",
"fork",
"form",
"fort",
"foul",
"four",
"free",
"from",
"fuel",
"full",
"fund",
"gain",
"game",
"gang",
"gate",
"gave",
"gear",
"gene",
"gift",
"girl",
"give",
"glad",
"glow",
"glue",
"goal",
"goat",
"gods",
"goes",
"gold",
"golf",
"gone",
"good",
"gray",
"grew",
"grey",
"grid",
"grim",
"grin",
"grip",
"grow",
"gulf",
"hair",
"half",
"hall",
"halt",
"hand",
"hang",
"hard",
"harm",
"hate",
"have",
"hawk",
"head",
"heal",
"hear",
"heat",
"held",
"hell",
"help",
"herb",
"here",
"hero",
"hide",
"high",
"hill",
"hint",
"hire",
"hold",
"hole",
"holy",
"home",
"hood",
"hook",
"hope",
"horn",
"host",
"hour",
"huge",
"hung",
"hunt",
"hurt",
"icon",
"idea",
"inch",
"into",
"iron",
"item",
"jail",
"jane",
"jazz",
"jean",
"john",
"join",
"joke",
"juan",
"jump",
"june",
"jury",
"just",
"keen",
"keep",
"kent",
"kept",
"kick",
"kids",
"kill",
"kind",
"king",
"kiss",
"knee",
"knew",
"know",
"lack",
"lady",
"laid",
"lake",
"lamb",
"lamp",
"land",
"lane",
"last",
"late",
"lead",
"leaf",
"lean",
"left",
"lend",
"lens",
"less",
"levy",
"lied",
"life",
"lift",
"like",
"lily",
"line",
"link",
"lion",
"list",
"live",
"load",
"loan",
"lock",
"lodge",
"loft",
"logo",
"long",
"look",
"loop",
"lord",
"lose",
"loss",
"lost",
"loud",
"love",
"luck",
"lung",
"made",
"maid",
"mail",
"main",
"make",
"male",
"mall",
"many",
"mark",
"mars",
"mask",
"mass",
"mate",
"math",
"mayo",
"maze",
"meal",
"mean",
"meat",
"meet",
"melt",
"menu",
"mess",
"mice",
"mild",
"mile",
"milk",
"mill",
"mind",
"mine",
"mint",
"miss",
"mist",
"mode",
"mood",
"moon",
"more",
"most",
"move",
"much",
"mule",
"must",
"myth",
"nail",
"name",
"navy",
"near",
"neat",
"neck",
"need",
"news",
"next",
"nice",
"nick",
"nine",
"noah",
"node",
"none",
"noon",
"norm",
"nose",
"note",
"noun",
"nuts",
"okay",
"once",
"ones",
"only",
"onto",
"open",
"oral",
"oven",
"over",
"pace",
"pack",
"page",
"paid",
"pain",
"pair",
"palm",
"park",
"part",
"pass",
"past",
"path",
"peak",
"pick",
"pier",
"pike",
"pile",
"pill",
"pine",
"pink",
"pipe",
"plan",
"play",
"plot",
"plug",
"plus",
"poem",
"poet",
"pole",
"poll",
"pond",
"pony",
"pool",
"poor",
"pope",
"pork",
"port",
"pose",
"post",
"pour",
"pray",
"prep",
"prey",
"pull",
"pump",
"pure",
"push",
"quit",
"race",
"rack",
"rage",
"raid",
"rail",
"rain",
"rank",
"rare",
"rate",
"rays",
"read",
"real",
"rear",
"rely",
"rent",
"rest",
"rice",
"rich",
"ride",
"ring",
"rise",
"risk",
"road",
"rock",
"rode",
"role",
"roll",
"roof",
"room",
"root",
"rope",
"rose",
"ross",
"ruin",
"rule",
"rush",
"ruth",
"safe",
"saga",
"sage",
"said",
"sail",
"sake",
"sale",
"salt",
"same",
"sand",
"sank",
"save",
"says",
"scan",
"scar",
"seal",
"seat",
"seed",
"seek",
"seem",
"seen",
"self",
"sell",
"semi",
"send",
"sent",
"sept",
"sets",
"shed",
"ship",
"shop",
"shot",
"show",
"shut",
"sick",
"side",
"sign",
"silk",
"sing",
"sink",
"site",
"size",
"skin",
"skip",
"slam",
"slap",
"slip",
"slow",
"snap",
"snow",
"soft",
"soil",
"sold",
"sole",
"some",
"song",
"soon",
"sort",
"soul",
"spot",
"star",
"stay",
"stem",
"step",
"stir",
"stop",
"such",
"suit",
"sung",
"sunk",
"sure",
"swim",
"tail",
"take",
"tale",
"talk",
"tall",
"tank",
"tape",
"task",
"team",
"tear",
"tech",
"tell",
"tend",
"tent",
"term",
"test",
"text",
"than",
"that",
"them",
"then",
"they",
"thin",
"this",
"thus",
"tide",
"tied",
"tier",
"ties",
"till",
"time",
"tiny",
"tips",
"tire",
"told",
"toll",
"tone",
"tony",
"took",
"tool",
"tops",
"torn",
"toss",
"tour",
"town",
"tray",
"tree",
"trek",
"trim",
"trio",
"trip",
"true",
"tube",
"tune",
"turn",
"twin",
"type",
"unit",
"upon",
"used",
"user",
"vary",
"vast",
"verb",
"very",
"vice",
"view",
"visa",
"void",
"vote",
"wade",
"wage",
"wait",
"wake",
"walk",
"wall",
"ward",
"warm",
"warn",
"wash",
"wave",
"ways",
"weak",
"wear",
"week",
"well",
"went",
"were",
"west",
"what",
"when",
"whom",
"wide",
"wife",
"wild",
"will",
"wind",
"wine",
"wing",
"wire",
"wise",
"wish",
"with",
"wolf",
"wood",
"wool",
"word",
"wore",
"work",
"worm",
"worn",
"wrap",
"yard",
"yeah",
"year",
"your",
"zone",
"zoom"
];
// Try to fetch random words from API, fallback to local list
async function getRandomWords(count = 4): Promise<string[]> {
try {
// Try Random Word API first
const response = await fetch(
`https://random-word-api.herokuapp.com/word?number=${count}`
);
if (response.ok) {
const words = await response.json();
if (Array.isArray(words) && words.length === count) {
return words;
}
}
} catch (error) {
console.warn("[RandomWords] API failed, using fallback words:", error);
}
// Fallback: pick random words from local list
const words: string[] = [];
const usedIndices = new Set<number>();
while (words.length < count) {
const index = Math.floor(Math.random() * FALLBACK_WORDS.length);
if (!usedIndices.has(index)) {
usedIndices.add(index);
words.push(FALLBACK_WORDS[index]);
}
}
return words;
}
return {
getRandomWords
};
}

View File

@@ -0,0 +1,346 @@
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
interface WatchStats {
totalHours: number;
totalPlays: number;
moviePlays: number;
episodePlays: number;
musicPlays: number;
}
interface DayStats {
date: string;
plays: number;
duration: number;
}
interface HomeStatItem {
rating_key: number;
title: string;
total_plays?: number;
total_duration?: number;
users_watched?: string;
last_play?: number;
grandparent_thumb?: string;
thumb?: string;
content_rating?: string;
labels?: string[];
media_type?: string;
}
interface PlaysGraphData {
categories: string[];
series: {
name: string;
data: number[];
}[];
}
export function useTautulliStats() {
// Helper function to make Tautulli API calls
async function tautulliRequest(
cmd: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams({
apikey: TAUTULLI_API_KEY,
cmd,
...params
});
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Tautulli API request failed: ${response.statusText}`);
}
const data = await response.json();
if (data.response?.result !== "success") {
throw new Error(data.response?.message || "Unknown API error");
}
return data.response.data;
} catch (error) {
console.error(`[Tautulli] Error with ${cmd}:`, error);
throw error;
}
}
// Fetch home statistics (pre-aggregated by Tautulli!)
async function fetchHomeStats(
userId?: number,
timeRange = 30,
statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> {
try {
const params: Record<string, any> = {
time_range: timeRange,
stats_type: statsType,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("get_home_stats", params);
// Extract stats from the response
let totalPlays = 0;
let totalHours = 0;
let moviePlays = 0;
let episodePlays = 0;
let musicPlays = 0;
// Find the relevant stat sections
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
if (topMovies?.rows) {
moviePlays = topMovies.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
if (topTV?.rows) {
episodePlays = topTV.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
if (topMusic?.rows) {
musicPlays = topMusic.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
totalPlays = moviePlays + episodePlays + musicPlays;
// Calculate total hours from duration
if (statsType === "duration") {
const totalDuration = [topMovies, topTV, topMusic].reduce(
(sum, stat) => {
if (!stat?.rows) return sum;
return (
sum +
stat.rows.reduce(
(s: number, item: any) => s + (item.total_duration || 0),
0
)
);
},
0
);
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
}
return {
totalHours,
totalPlays,
moviePlays,
episodePlays,
musicPlays
};
} catch (error) {
console.error("[Tautulli] Error fetching home stats:", error);
return {
totalHours: 0,
totalPlays: 0,
moviePlays: 0,
episodePlays: 0,
musicPlays: 0
};
}
}
// Fetch plays by date (already aggregated by Tautulli!)
async function fetchPlaysByDate(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<DayStats[]> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_date",
params
);
// Sum all series data for each date
return data.categories.map((date, index) => {
const totalValue = data.series
.filter(s => s.name !== "Total")
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
return {
date,
plays: yAxis === "plays" ? totalValue : 0,
duration: yAxis === "duration" ? totalValue : 0
};
});
} catch (error) {
console.error("[Tautulli] Error fetching plays by date:", error);
return [];
}
}
// Fetch plays by day of week (already aggregated!)
async function fetchPlaysByDayOfWeek(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{
labels: string[];
movies: number[];
episodes: number[];
music: number[];
}> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_dayofweek",
params
);
// Map series names to our expected format
const movies =
data.series.find(s => s.name === "Movies")?.data ||
new Array(7).fill(0);
const episodes =
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
const music =
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
return {
labels: data.categories,
movies,
episodes,
music
};
} catch (error) {
console.error("[Tautulli] Error fetching plays by day of week:", error);
return {
labels: [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
movies: new Array(7).fill(0),
episodes: new Array(7).fill(0),
music: new Array(7).fill(0)
};
}
}
// Fetch plays by hour of day (already aggregated!)
async function fetchPlaysByHourOfDay(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{ labels: string[]; data: number[] }> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_hourofday",
params
);
// Sum all series data for each hour
const hourlyData = data.categories.map((hour, index) =>
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
);
return {
labels: data.categories.map(h => `${h}:00`),
data: hourlyData
};
} catch (error) {
console.error("[Tautulli] Error fetching plays by hour:", error);
return {
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
data: new Array(24).fill(0)
};
}
}
// Fetch top watched content from home stats
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
try {
const params: Record<string, any> = {
time_range: timeRange,
stats_type: "plays",
stats_count: limit,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("get_home_stats", params);
// Get "last_watched" stat which contains recent items
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
if (!lastWatched?.rows) {
return [];
}
return lastWatched.rows.slice(0, limit).map((item: any) => ({
title: item.title || item.full_title || "Unknown",
plays: item.total_plays || 0,
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
type: item.media_type || "unknown"
}));
} catch (error) {
console.error("[Tautulli] Error fetching top content:", error);
return [];
}
}
return {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
};
}

View File

@@ -1,3 +1,4 @@
/*
let setValue = function(el, binding) {
let value = binding.value;
let dateArray = value.split('-');
@@ -13,3 +14,4 @@ module.exports = {
setValue(el, binding);
}
}
*/

View File

@@ -1,5 +1,6 @@
let setValue = function(el, binding) {
let img = new Image();
/*
const setValue = function(el, binding) {
const img = new Image();
img.src = binding.value;
img.onload = function() {
@@ -10,10 +11,11 @@ let setValue = function(el, binding) {
module.exports = {
isLiteral: true,
bind(el, binding){
bind(el, binding) {
setValue(el, binding);
},
update(el, binding){
update(el, binding) {
setValue(el, binding);
}
}
};
*/

View File

@@ -1,5 +1,11 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg
version="1.1"
height="100%"
width="100%"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
>
<path
d="M28.725 8.058l-12.725 12.721-12.725-12.721-1.887 1.887 13.667 13.667c0.258 0.258 0.6 0.392 0.942 0.392s0.683-0.129 0.942-0.392l13.667-13.667-1.879-1.887z"
/>

8
src/icons/IconClock.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
d="M16 3c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13-5.82-13-13-13zM16 26.667c-5.891 0-10.667-4.776-10.667-10.667s4.776-10.667 10.667-10.667c5.891 0 10.667 4.776 10.667 10.667s-4.776 10.667-10.667 10.667z"
/>
<path d="M17.167 9.333h-2.333v8l7 4.2 1.167-1.9-5.833-3.467z" />
</svg>
</template>

7
src/icons/IconMusic.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
d="M28 4.667v19.333c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-12.133l-13.333 3.8v16.2c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-17.8c0-0.6 0.4-1.133 0.967-1.267l14.667-4.133c0.133-0.033 0.267-0.067 0.4-0.067 0.733 0 1.333 0.6 1.333 1.333zM6.333 24c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333zM25.667 7.2l-11.333 3.2v-2.2l11.333-3.2v2.2zM22.333 20.667c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333z"
/>
</svg>
</template>

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-use-before-define */
import { MediaTypes } from "./IList";
export interface IAutocompleteResult {
@@ -30,7 +29,7 @@ export interface Hits {
}
export interface Hit {
_index: Index;
_index: string;
_type: Type;
_id: string;
_score: number;
@@ -59,11 +58,6 @@ export interface Option {
_source: Source;
}
export enum Index {
Movies = "movies",
Shows = "shows"
}
export interface Source {
tags: Tag[];
ecs: Ecs;
@@ -80,7 +74,7 @@ export interface Source {
original_title: string;
original_name?: string;
name?: string;
type?: MediaTypes;
type: string;
}
export interface Agent {

View File

@@ -0,0 +1,5 @@
export interface IColors {
bg: string;
p: string;
s?: string;
}

View File

@@ -1,5 +1,3 @@
/* eslint-disable no-use-before-define */
export enum GraphTypes {
Plays = "plays",
Duration = "duration"
@@ -12,12 +10,12 @@ export enum GraphValueTypes {
export interface IGraphDataset {
name: string;
data: Array<number>;
data: number[];
}
export interface IGraphData {
labels: Array<string>;
series: Array<IGraphDataset>;
labels: string[];
series: IGraphDataset[];
}
export interface IGraphResponse {

View File

@@ -67,7 +67,7 @@ export interface IMovie {
backdrop: string;
release_date: string | Date;
rating: number;
genres: Array<MovieGenres>;
genres: MovieGenres[];
production_status: MovieProductionStatus;
tagline: string;
runtime: number;
@@ -88,9 +88,9 @@ export interface IShow {
seasons?: number;
episodes?: number;
popularity?: number;
genres?: Array<ShowGenres>;
genres?: ShowGenres[];
production_status?: string;
runtime?: Array<number>;
runtime?: number[];
exists_in_plex?: boolean;
type: MediaTypes.Show;
}
@@ -135,19 +135,19 @@ export interface ICrew {
}
export interface IMediaCredits {
cast: Array<ICast>;
crew: Array<ICrew>;
cast: ICast[];
crew: ICrew[];
id: number;
}
export interface IPersonCredits {
cast: Array<IMovie | IShow>;
crew: Array<ICrew>;
cast: (IMovie | IShow)[];
crew: ICrew[];
id: number;
type?: string;
}
export type ListResults = Array<IMovie | IShow | IPerson | IRequest>;
export type ListResults = (IMovie | IShow | IPerson | IRequest)[];
export interface IList {
results: ListResults;

View File

@@ -1,7 +1,7 @@
export default interface INavigationIcon {
title: string;
route: string;
icon: any; // eslint-disable-line @typescript-eslint/no-explicit-any
icon: any;
requiresAuth?: boolean;
useStroke?: boolean;
}

View File

@@ -1,6 +1,6 @@
import type ITorrent from "./ITorrent";
export default interface IStateTorrent {
results: Array<ITorrent>;
results: ITorrent[];
resultCount: number | null;
}

View File

@@ -7,5 +7,5 @@ export default interface ITorrent {
seed: string;
leech: string;
url: string | null;
release_type: Array<string>;
release_type: string[];
}

View File

@@ -3,8 +3,39 @@ import router from "./routes";
import store from "./store";
import Toast from "./plugins/Toast";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const App = require("./App.vue").default;
import App from "./App.vue";
// Initialize theme from localStorage
function initTheme() {
const savedTheme = localStorage.getItem("theme-preference") || "auto";
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
if (savedTheme === "auto") {
const systemDark = systemDarkModeEnabled();
document.body.className = systemDark ? "dark" : "light";
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", e => {
const currentTheme = localStorage.getItem("theme-preference");
if (currentTheme === "auto") {
document.body.className = e.matches ? "dark" : "light";
}
});
} else {
document.body.className = savedTheme;
}
}
// Initialize theme before mounting
initTheme();
store.dispatch("darkmodeModule/findAndSetDarkmodeSupported");
store.dispatch("user/initUserFromCookie");
@@ -14,4 +45,5 @@ const app = createApp(App);
app.use(router);
app.use(store);
app.use(Toast);
app.mount("#entry");
app.mount("#app");

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import IStateDarkmode from "../interfaces/IStateDarkmode";
const state: IStateDarkmode = {
@@ -10,9 +11,7 @@ export default {
namespaced: true,
state,
getters: {
darkmodeSupported: (state: IStateDarkmode) => {
return state.darkmodeSupported;
}
darkmodeSupported: (state: IStateDarkmode) => state.darkmodeSupported
},
mutations: {
SET_DARKMODE_SUPPORT: (

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import type IStateDocumentTitle from "../interfaces/IStateDocumentTitle";
const capitalize = (string: string) => {
@@ -26,7 +27,7 @@ const state: IStateDocumentTitle = {
title: undefined
};
/* eslint-disable @typescript-eslint/no-shadow, no-return-assign */
/* eslint-disable @typescript-eslint/no-shadow */
export default {
namespaced: true,
state,

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import type IStateHamburger from "../interfaces/IStateHamburger";
const state: IStateHamburger = {

View File

@@ -1,15 +1,16 @@
/* eslint-disable no-param-reassign */
import { MediaTypes } from "../interfaces/IList";
import type { IStatePopup, IPopupQuery } from "../interfaces/IStatePopup";
/* eslint-disable-next-line import/no-cycle */
/* eslint-disable-next-line import-x/no-cycle */
import router from "../routes";
const removeIncludedQueryParams = (params, key) => {
const removeIncludedQueryParams = (params: URLSearchParams, key: string) => {
if (params.has(key)) params.delete(key);
return params;
};
function paramsToObject(entries) {
function paramsToObject(entries: Iterator<[string, string]>) {
const result = {};
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of entries) {
@@ -65,7 +66,7 @@ export default {
actions: {
open: ({ commit }, { id, type }: { id: number; type: MediaTypes }) => {
if (!Number.isNaN(id)) {
id = Number(id); /* eslint-disable-line no-param-reassign */
id = Number(id);
}
commit("SET_OPEN", { id, type });

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import type ITorrent from "../interfaces/ITorrent";
import type IStateTorrent from "../interfaces/IStateTorrent";
@@ -11,16 +12,12 @@ export default {
namespaced: true,
state,
getters: {
results: (state: IStateTorrent) => {
return state.results;
},
resultCount: (state: IStateTorrent) => {
return state.resultCount;
}
results: (state: IStateTorrent) => state.results,
resultCount: (state: IStateTorrent) => state.resultCount
},
mutations: {
SET_RESULTS: (state: IStateTorrent, results: Array<ITorrent>) => {
SET_RESULTS: (state: IStateTorrent, results: ITorrent[]) => {
state.results = results;
},
SET_RESULT_COUNT: (state: IStateTorrent, count: number) => {
@@ -32,7 +29,7 @@ export default {
}
},
actions: {
setResults({ commit }, results: Array<ITorrent>) {
setResults({ commit }, results: ITorrent[]) {
commit("SET_RESULTS", results);
},
setResultCount({ commit }, count: number) {

View File

@@ -1,117 +0,0 @@
import { refreshToken } from "@/api";
import { parseJwt } from "@/utils";
function getCookie(name) {
var arrayb = document.cookie.split(";");
for (const item of arrayb) {
const query = `${name}=`;
if (!item.startsWith(query)) continue;
return item.substr(query.length);
}
return null;
}
function setCookie(name, value, options = {}) {
options = {
path: "/",
// add other defaults here if necessary
...options
};
if (options.expires instanceof Date) {
options.expires = options.expires.toUTCString();
}
let updatedCookie =
encodeURIComponent(name) + "=" + encodeURIComponent(value);
for (let optionKey in options) {
updatedCookie += "; " + optionKey;
let optionValue = options[optionKey];
if (optionValue !== true) {
updatedCookie += "=" + optionValue;
}
}
document.cookie = updatedCookie;
}
function deleteCookie(name) {
setCookie(name, "", {
"max-age": Date.now()
});
}
export default {
namespaced: true,
state: {
token: null,
admin: false,
settings: null,
username: null
},
getters: {
username: state => state.username,
settings: state => state.settings,
token: state => state.token,
// loggedIn: state => true,
loggedIn: state => state && state.username !== null,
admin: state => state.admin,
plexUserId: state => {
if (state && state.settings && state.settings.plexUserId)
return state.settings.plexUserId;
return null;
}
},
mutations: {
SET_TOKEN: (state, token) => (state.token = token),
SET_USERNAME: (state, username) => (state.username = username),
SET_SETTINGS: (state, settings) => (state.settings = settings),
SET_ADMIN: (state, admin) => (state.admin = admin),
LOGOUT: state => {
state.token = null;
state.username = null;
state.settings = null;
state.admin = false;
// deleteCookie('authorization');
}
},
actions: {
initUserFromCookie: async ({ dispatch }) => {
const jwtToken = getCookie("authorization");
if (!jwtToken) return null;
const token = parseJwt(jwtToken);
return await dispatch("setupStateFromToken", token);
},
setupStateFromToken: ({ commit }, token) => {
try {
const { username, admin, settings } = token;
if (!username) {
return false;
}
commit("SET_TOKEN", token);
commit("SET_USERNAME", username);
commit("SET_SETTINGS", settings);
commit("SET_ADMIN", admin != undefined);
return true;
} catch (error) {
console.error("Unable to parse JWT, failed with error:", error);
return false;
}
},
setSettings: ({ commit }, settings) => {
if (!(settings instanceof Object)) {
throw "Parameter is not a object.";
}
commit("SET_SETTINGS", settings);
},
logout: ({ commit }) => commit("LOGOUT"),
login: async ({ dispatch }) => await dispatch("initUserFromCookie")
}
};

178
src/modules/user.ts Normal file
View File

@@ -0,0 +1,178 @@
/* eslint-disable no-param-reassign */
import { Module } from "vuex";
import { parseJwt } from "../utils";
/* --------------------------------------------------------------------------- */
/* ── Utility helpers (cookie handling) ────────────────────────────────── */
/* --------------------------------------------------------------------------- */
export interface CookieOptions {
path?: string;
expires?: number | string | boolean;
[option: string]: string | number | boolean | undefined;
}
/**
* Read a cookie value.
*/
export function getCookie(name: string): string | null {
const array = document.cookie.split(";");
let match = null;
array.forEach((item: string) => {
const query = `${name}=`;
if (!item.trim().startsWith(query)) return;
match = item.trim().substring(query.length);
});
return match;
}
/**
* Write a cookie.
*/
export function setCookie(
name: string,
value: string,
options: CookieOptions = {}
): void {
const opts: CookieOptions = {
path: "/",
...options
};
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
/* eslint-disable-next-line no-restricted-syntax */
for (const [key, val] of Object.entries(opts)) {
cookie += `; ${key}`;
if (val !== true) cookie += `=${val}`;
}
document.cookie = cookie;
}
/**
* Delete a cookie.
*/
export function deleteCookie(name: string): void {
setCookie(name, "", { "max-age": 0 });
}
/* --------------------------------------------------------------------------- */
/* ── State / Types ─────────────────────────────────────────────────── */
/* --------------------------------------------------------------------------- */
export interface Settings {
/** Example property replace with your real shape. */
plexUserId?: string | null;
// add the rest of your settings fields here
}
export interface UserState {
token: string | null;
admin: boolean;
settings: Settings | null;
username: string | null;
}
export interface RootState {
val?: string;
// your root state interface leave empty if you don't use it
}
/* --------------------------------------------------------------------------- */
/* ── Vuex module ──────────────────────────────────────────────────────── */
/* --------------------------------------------------------------------------- */
const userModule: Module<UserState, RootState> = {
namespaced: true,
/* ── State ───────────────────────────────────────────────────── */
state: {
token: null,
admin: false,
settings: null,
username: null
},
/* ── Getters ─────────────────────────────────────────────────── */
getters: {
username: (state): string | null => state.username,
settings: (state): Settings | null => state.settings,
token: (state): string | null => state.token,
loggedIn: (state): boolean => !!state && state.username !== null,
admin: (state): boolean => state.admin,
plexUserId: (state): string | null => state?.settings?.plexUserId ?? null
},
/* ── Mutations ─────────────────────────────────────────────────── */
mutations: {
SET_TOKEN(state, token: string | null): void {
state.token = token;
},
SET_USERNAME(state, username: string | null): void {
state.username = username;
},
SET_SETTINGS(state, settings: Settings | null): void {
state.settings = settings;
},
SET_ADMIN(state, admin: boolean): void {
state.admin = admin;
},
LOGOUT(state): void {
state.token = null;
state.username = null;
state.settings = null;
state.admin = false;
deleteCookie("authorization");
}
},
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
const jwtToken = getCookie("authorization");
if (!jwtToken) return null;
const token = parseJwt(jwtToken);
return dispatch("setupStateFromToken", token);
},
setupStateFromToken({ commit }, token: any): boolean {
try {
const { username, admin, settings } = token;
if (!username) return false;
commit("SET_TOKEN", token);
commit("SET_USERNAME", username);
commit("SET_SETTINGS", settings);
commit("SET_ADMIN", admin !== undefined);
return true;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Unable to parse JWT, failed with error:", e);
return false;
}
},
setSettings({ commit }, settings: Settings): void {
if (!(settings instanceof Object)) {
throw new Error("Parameter is not an object.");
}
commit("SET_SETTINGS", settings);
},
logout({ commit }): void {
commit("LOGOUT");
},
login({ dispatch }): Promise<boolean | null> {
return dispatch("initUserFromCookie");
}
}
};
const user = userModule;
export default user;

View File

@@ -1,20 +1,28 @@
<template>
<div>
<section class="not-found" :style="backgroundImageCSS">
<h1 class="not-found__title">Page Not Found</h1>
<seasoned-button class="button" @click="goBack">
go back to previous page
</seasoned-button>
<section class="not-found">
<div class="not-found__content">
<h1 class="not-found__title">404</h1>
<p class="not-found__subtitle">Page Not Found</p>
<div v-if="quote.text" class="quote">
&ldquo;{{ quote.text }}&rdquo;
<span v-if="quote.movie" class="quote__movie">
- {{ quote.movie }} {{ quote.year }}
</span>
</div>
<seasoned-button class="button" @click="goBack">
Go Back
</seasoned-button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useStore } from "vuex";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
const backgroundImageCSS =
'background-image: url("/assets/pulp-fiction.jpg")';
import { ref } from "vue";
const store = useStore();
@@ -22,55 +30,310 @@
store.dispatch("popup/close");
}
const isMounted = ref(false);
const quotes = [
// --- Disney / Pixar 90s & early ---
{ text: "Hakuna Matata", movie: "The Lion King", year: "1994" },
{ text: "To infinity and beyond!", movie: "Toy Story", year: "1995" },
{ text: "You're my favorite deputy.", movie: "Toy Story", year: "1995" },
{
text: "I have a brilliant beyond brilliant idea!",
movie: "The Parent Trap",
year: "1998"
},
{
text: "I'm not bad, I'm just drawn that way.",
movie: "Who Framed Roger Rabbit",
year: "1988"
},
// --- Classic 90s movies ---
{
text: "Life was like a box of chocolates.",
movie: "Forrest Gump",
year: "1994"
},
{
text: "Hasta la vista, baby.",
movie: "Terminator 2: Judgment Day",
year: "1991"
},
{
text: "There's no crying in baseball!",
movie: "A League of Their Own",
year: "1992"
},
{
text: "Get busy livin or get busy dyin",
movie: "The Shawshank Redemption",
year: "1994"
},
{ text: "Im the king of the world!", movie: "Titanic", year: "1997" },
{ text: "You had me at hello.", movie: "Jerry Maguire", year: "1996" },
{ text: "Show me the money!", movie: "Jerry Maguire", year: "1996" },
{
text: "Yippee-ki-yay …",
movie: "Die Hard with a Vengeance",
year: "1995"
},
{ text: "Youre gonna need a bigger boat.", movie: "Jaws", year: "1975" },
{ text: "I see dead people.", movie: "The Sixth Sense", year: "1999" },
{ text: "Why so serious?", movie: "The Dark Knight", year: "2008" },
{ text: "Just keep swimming.", movie: "Finding Nemo", year: "2003" },
{ text: "Ill be back.", movie: "The Terminator", year: "1984" },
// --- Cult comedy quotes ---
{
text: "Stay classy, San Diego.",
movie: "Anchorman: The Legend of Ron Burgundy",
year: "2004"
},
{
text: "Milk was a bad choice.",
movie: "Anchorman: The Legend of Ron Burgundy",
year: "2004"
},
{
text: "60% of the time, it works every time.",
movie: "Anchorman: The Legend of Ron Burgundy",
year: "2004"
},
{
text: "I love lamp.",
movie: "Anchorman: The Legend of Ron Burgundy",
year: "2004"
},
{
text: "Well that escalated quickly.",
movie: "Anchorman: The Legend of Ron Burgundy",
year: "2004"
},
// --- A24 & Modern Indie ---
{
text: "In another life, I would have really liked just doing laundry and taxes with you.",
movie: "Everything Everywhere All at Once",
year: "2022"
},
{
text: "Every rejection, every disappointment has led you here.",
movie: "Everything Everywhere All at Once",
year: "2022"
},
{
text: "Whatever you plan on happening, never happens.",
movie: "Cmon Cmon",
year: "2021"
},
{ text: "We made promises, Harper.", movie: "Men", year: "2022" },
// (A24 quotes are harder to find officially listed, so these are standout lines from fans and quote compilations.) :contentReference[oaicite:1]{index=1}
// --- Grand iconic movie quotes ---
{ text: "May the Force be with you.", movie: "Star Wars", year: "1977" },
{
text: "Frankly, my dear, I don't give a damn.",
movie: "Gone with the Wind",
year: "1939"
},
{
text: "I love the smell of napalm in the morning.",
movie: "Apocalypse Now",
year: "1979"
},
{
text: "Toto, I've a feeling we're not in Kansas anymore.",
movie: "The Wizard of Oz",
year: "1939"
},
{ text: "Here's looking at you, kid.", movie: "Casablanca", year: "1942" },
{
text: "You can't handle the truth!",
movie: "A Few Good Men",
year: "1992"
},
{ text: "Bond. James Bond.", movie: "Dr. No", year: "1962" },
{ text: "Houston, we have a problem.", movie: "Apollo 13", year: "1995" },
{ text: "I see dead people.", movie: "The Sixth Sense", year: "1999" },
{ text: "Rosebud.", movie: "Citizen Kane", year: "1941" },
{ text: "Plastics.", movie: "The Graduate", year: "1967" },
{ text: "You talkin to me?", movie: "Taxi Driver", year: "1976" },
{
text: "Fasten your seatbelts. It's going to be a bumpy night.",
movie: "All About Eve",
year: "1950"
},
{ text: "Go ahead, make my day.", movie: "Sudden Impact", year: "1983" },
// --- More quotable modern lines ---
{
text: "With great power comes great responsibility.",
movie: "SpiderMan",
year: "2002"
},
{
text: "Youre a wizard, Harry.",
movie: "Harry Potter and the Sorcerers Stone",
year: "2001"
},
{
text: "I am your father.",
movie: "Star Wars: The Empire Strikes Back",
year: "1980"
},
{ text: "Wakanda Forever!", movie: "Black Panther", year: "2018" },
{ text: "I am Iron Man.", movie: "Iron Man", year: "2008" },
{ text: "Avengers, assemble!", movie: "Avengers: Endgame", year: "2019" },
{ text: "Well always have Paris.", movie: "Casablanca", year: "1942" },
{
text: "Just when I thought I was out, they pull me back in.",
movie: "The Godfather Part III",
year: "1990"
},
{
text: "I drink your milkshake!",
movie: "There Will Be Blood",
year: "2007"
},
// --- Crowdsourced favorite 90s lines ---
{
text: "The greatest trick the Devil ever pulled …",
movie: "The Usual Suspects",
year: "1995"
},
{ text: "English, motherfucker …", movie: "Pulp Fiction", year: "1994" },
{
text: "As far back as I can remember …",
movie: "Goodfellas",
year: "1990"
},
{ text: "Run, Forrest, run!", movie: "Forrest Gump", year: "1994" }
];
const quote = ref({
text: "404 - Page Not Found",
movie: "",
year: ""
});
onMounted(() => {
isMounted.value = true;
quote.value = quotes[Math.floor(Math.random() * quotes.length)];
});
function goBack() {
window.history.go(-2);
window.history.go(-1);
}
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
.button {
font-size: 1.2rem;
z-index: 10;
@include mobile {
font-size: 1rem;
width: content;
}
}
@import "scss/variables.scss";
@import "scss/media-queries.scss";
.not-found {
position: relative;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: calc(100vh - var(--header-size));
min-height: calc(100vh - var(--header-size));
background:
linear-gradient(135deg, #1a1a2e 0%, rgba(0, 0, 0, 0.5) 100%),
url("/assets/pulp-fiction.jpg");
background-size: cover;
background-position: 50% 50%;
background-repeat: no-repeat no-repeat;
background-position: center;
background-blend-mode: multiply;
color: white;
overflow: hidden;
&::before {
content: "";
position: absolute;
height: calc(100vh - var(--header-size));
width: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
circle at 50% 50%,
rgba(120, 65, 255, 0.1) 0%,
transparent 70%
);
pointer-events: none;
background: var(--background-40);
}
&__content {
text-align: center;
z-index: 10;
padding: 2rem;
@include mobile {
padding: 1rem;
}
}
&__title {
font-size: 2.5rem;
font-weight: 500;
padding: 0 1rem;
color: var(--text-color);
position: relative;
background-color: var(--background-90);
font-size: clamp(4rem, 10vw, 8rem);
font-weight: 800;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
color: white;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
}
@include tablet-min {
font-size: 3.5rem;
}
&__subtitle {
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 300;
margin: 0.5rem 0 2rem;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 0.05em;
}
}
.quote {
font-family: "Georgia", serif;
font-style: italic;
font-size: clamp(1.2rem, 3vw, 1.8rem);
margin: 1.5rem 0;
color: rgba(255, 255, 255, 0.9);
line-height: 1.6;
max-width: 600px;
text-align: center;
padding: 0 1rem;
@include mobile {
font-size: clamp(1rem, 2.5vw, 1.4rem);
}
}
.quote__movie {
display: block;
font-size: clamp(0.9rem, 1.8vw, 1.2rem);
margin-top: 0.5rem;
color: rgba(255, 255, 255, 0.7);
font-style: normal;
opacity: 0.8;
font-family: "Arial", sans-serif;
font-weight: 400;
@include mobile {
font-size: clamp(0.8rem, 1.5vw, 1rem);
}
}
.button {
font-size: clamp(1rem, 2vw, 1.4rem);
font-weight: 600;
padding: 1rem 2.5rem;
background: rgba(255, 255, 255, 0.9);
color: #1a1a2e;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
&:hover {
background: white;
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.3);
}
@include mobile {
padding: 0.8rem 2rem;
font-size: 1rem;
}
}
</style>

View File

@@ -1,73 +1,134 @@
<template>
<div v-if="plexUserId" class="wrapper">
<h1>Your watch activity</h1>
<div v-if="plexUserId && plexUsername" class="activity">
<h1 class="activity__title">Watch Activity</h1>
<div style="display: flex; flex-direction: row">
<label class="filter" for="dayinput">
<span>Days:</span>
<input
id="dayinput"
v-model="days"
class="dayinput"
placeholder="days"
type="number"
pattern="[0-9]*"
@change="fetchChartData"
/>
</label>
<!-- Stats Overview -->
<div v-if="watchStats" class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalPlays }}</div>
<div class="stat-label">Total Plays</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalHours }}h</div>
<div class="stat-label">Watch Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.moviePlays }}</div>
<div class="stat-label">Movies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes</div>
</div>
</div>
<div class="filter">
<span>Data sorted by:</span>
<div class="controls">
<div class="control-group">
<label class="control-label">Time Range</label>
<div class="input-wrapper">
<input
v-model.number="days"
class="days-input"
type="number"
min="1"
max="365"
@change="fetchChartData"
/>
<span class="input-suffix">days</span>
</div>
</div>
<div class="control-group">
<label class="control-label">View Mode</label>
<toggle-button
v-model:selected="graphViewMode"
class="filter-item"
:options="[GraphTypes.Plays, GraphTypes.Duration]"
@change="fetchChartData"
/>
</div>
</div>
<div class="chart-section">
<h3 class="chart-header">Activity per day:</h3>
<div class="graph">
<Graph
v-if="playsByDayData"
:data="playsByDayData"
type="line"
:stacked="false"
:dataset-description-suffix="`watch last ${days} days`"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graph-value-type="selectedGraphViewMode.valueType"
/>
<div class="activity__charts">
<div class="chart-card">
<h3 class="chart-card__title">Daily Activity</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayData"
:data="playsByDayData"
type="line"
:stacked="false"
:dataset-description-suffix="`watch last ${days} days`"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graph-value-type="graphViewMode"
/>
</div>
</div>
<h3 class="chart-header">Activity per day of week:</h3>
<div class="graph">
<Graph
v-if="playsByDayofweekData"
:data="playsByDayofweekData"
type="bar"
:stacked="true"
:dataset-description-suffix="`watch last ${days} days`"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graph-value-type="selectedGraphViewMode.valueType"
/>
<div class="chart-card">
<h3 class="chart-card__title">Activity by Media Type</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayofweekData"
:data="playsByDayofweekData"
:graphValueType="graphViewMode"
type="bar"
:stacked="true"
:dataset-description-suffix="`watch last ${days} days`"
tooltip-description-suffix="plays"
/>
</div>
</div>
<div class="chart-card">
<h3 class="chart-card__title">Viewing Patterns by Hour</h3>
<div class="chart-card__graph">
<Graph
v-if="hourlyData"
:data="hourlyData"
type="bar"
:stacked="false"
:dataset-description-suffix="`last ${days} days`"
tooltip-description-suffix="plays"
:graph-value-type="graphViewMode"
/>
</div>
</div>
</div>
<!-- Top Content -->
<div v-if="topContent.length > 0" class="activity__top-content">
<h3 class="section-title">Most Watched</h3>
<div class="top-content-list">
<div
v-for="(item, index) in topContent"
:key="index"
class="top-content-item"
>
<div class="content-rank">{{ index + 1 }}</div>
<div class="content-details">
<div class="content-title">{{ item.title }}</div>
<div class="content-meta">
{{ item.type }} {{ item.plays }} plays {{ item.duration }}min
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="not-authenticated">
<h1><IconStop /> Must be authenticated</h1>
<h1><IconStop /> Must be authenticated with Plex</h1>
<p>Go to Settings to link your Plex account</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue";
import type { Ref } from "vue";
import { fetchGraphData } from "../api";
import { useTautulliStats } from "@/composables/useTautulliStats";
import {
GraphTypes,
GraphValueTypes,
@@ -78,7 +139,36 @@
const days: Ref<number> = ref(30);
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
const plexUserId = computed(() => store.getters["user/plexUserId"]);
// Check both Vuex store and localStorage for Plex user
const plexUserId = computed(() => {
// First try Vuex store
const storeId = store.getters["user/plexUserId"];
if (storeId) return storeId;
// Fallback to localStorage
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).id;
} catch {
return null;
}
}
return null;
});
const plexUsername = computed(() => {
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).username;
} catch {
return null;
}
}
return null;
});
const graphValueViewMode = [
{
@@ -95,85 +185,134 @@
const playsByDayData: Ref<IGraphData> = ref(null);
const playsByDayofweekData: Ref<IGraphData> = ref(null);
const hourlyData: Ref<IGraphData> = ref(null);
const watchStats = ref(null);
const topContent = ref([]);
const selectedGraphViewMode = computed(() =>
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
function convertDateStringToDayMonth(date: string): string {
const {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
} = useTautulliStats();
function convertDateStringToDayMonth(date: string, short = true): string {
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date;
}
const [, month, day] = date.split("-");
return `${day}.${month}`;
const [year, month, day] = date.split("-");
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
}
function convertDateLabels(data) {
return {
labels: data.categories.map(convertDateStringToDayMonth),
series: data.series
};
async function fetchChartData() {
if (!plexUserId.value) return;
try {
const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
const [homeStats, dayData, weekData, hourData, topContentData] =
await Promise.all([
fetchHomeStats(plexUserId.value, days.value, "duration"), // Need duration for hours
fetchPlaysByDate(days.value, yAxis, plexUserId.value),
fetchPlaysByDayOfWeek(days.value, yAxis, plexUserId.value),
fetchPlaysByHourOfDay(days.value, yAxis, plexUserId.value),
fetchTopContent(days.value, 10, plexUserId.value)
]);
// Set overall stats
watchStats.value = homeStats;
// Set top content
topContent.value = topContentData;
// Activity per day
playsByDayData.value = {
labels: dayData.map(d =>
convertDateStringToDayMonth(d.date, dayData.length < 365)
),
series: [
{
name: "Activity",
data:
graphViewMode.value === GraphTypes.Plays
? dayData.map(d => d.plays)
: dayData.map(d => d.duration)
}
]
};
// Activity by day of week (stacked by media type)
playsByDayofweekData.value = {
labels: weekData.labels,
series: [
{ name: "Movies", data: weekData.movies },
{ name: "Episodes", data: weekData.episodes },
{ name: "Music", data: weekData.music }
]
};
// Hourly distribution
hourlyData.value = {
labels: hourData.labels,
series: [{ name: "Plays", data: hourData.data }]
};
} catch (error) {
console.error("[ActivityPage] Error fetching chart data:", error);
}
}
async function fetchPlaysByDay() {
playsByDayData.value = await fetchGraphData(
"plays_by_day",
days.value,
graphViewMode.value
).then(data => convertDateLabels(data?.data));
}
async function fetchPlaysByDayOfWeek() {
playsByDayofweekData.value = await fetchGraphData(
"plays_by_dayofweek",
days.value,
graphViewMode.value
).then(data => convertDateLabels(data?.data));
}
function fetchChartData() {
fetchPlaysByDay();
fetchPlaysByDayOfWeek();
}
fetchChartData();
onMounted(() => {
if (plexUsername.value) {
fetchChartData();
}
});
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "scss/variables";
@import "scss/media-queries";
.wrapper {
padding: 2rem;
.activity {
padding: 3rem;
max-width: 100%;
@include mobile-only {
padding: 0 0.8rem;
}
}
.filter {
margin-top: 0.5rem;
display: inline-flex;
flex-direction: column;
font-size: 1.2rem;
&:not(:first-of-type) {
margin-left: 1.25rem;
padding: 0.75rem;
}
input {
width: 100%;
font-size: inherit;
max-width: 6rem;
background-color: $background-ui;
color: $text-color;
}
span {
font-size: inherit;
line-height: 1;
margin: 0.5rem 0;
&__title {
margin: 0 0 2rem 0;
font-size: 2rem;
font-weight: 300;
color: $text-color;
line-height: 1;
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
}
&__charts {
display: flex;
flex-direction: column;
gap: 1.5rem;
@include mobile-only {
gap: 1rem;
}
}
&__top-content {
margin-top: 2rem;
}
}
@@ -202,29 +341,227 @@
// }
// }
.chart-section {
display: flex;
flex-wrap: wrap;
.chart-card {
background: var(--background-ui);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--text-color-50);
.graph {
@include mobile-only {
padding: 1rem;
}
&__title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
@include mobile-only {
font-size: 1rem;
}
}
&__graph {
position: relative;
height: 35vh;
width: 90vw;
margin-bottom: 2rem;
min-height: 300px;
@include mobile-only {
height: 30vh;
min-height: 250px;
}
}
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.controls {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
flex-wrap: wrap;
@include mobile-only {
flex-direction: column;
gap: 1rem;
}
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
@include mobile-only {
min-width: 0;
width: 100%;
}
}
.control-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-wrapper {
display: flex;
align-items: center;
background: var(--background-ui);
border: 1px solid var(--text-color-50);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
&:hover,
&:focus-within {
border-color: var(--text-color);
}
}
.days-input {
flex: 1;
background: transparent;
border: none;
padding: 0.75rem 1rem;
font-size: 1rem;
color: $text-color;
outline: none;
width: 80px;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
opacity: 1;
}
}
.input-suffix {
padding: 0 1rem;
font-size: 0.9rem;
color: var(--text-color-60);
user-select: none;
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.stat-card {
background: var(--background-ui);
padding: 1.5rem;
border-radius: 12px;
text-align: center;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
}
.chart-header {
font-weight: 300;
@include mobile-only {
padding: 1rem;
}
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--highlight-color);
margin-bottom: 0.5rem;
@include mobile-only {
font-size: 2rem;
}
}
.stat-label {
font-size: 0.9rem;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 300;
@include mobile-only {
font-size: 0.8rem;
}
}
.top-content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.content-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: center;
}
.content-details {
flex: 1;
}
.content-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.content-meta {
font-size: 0.85rem;
color: var(--text-color-60);
}
.not-authenticated {
padding: 2rem;
text-align: center;
h1 {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 1rem;
svg {
margin-right: 1rem;
@@ -232,6 +569,12 @@
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;

108
src/pages/AdminPage.vue Normal file
View File

@@ -0,0 +1,108 @@
<template>
<section class="admin">
<h1 class="admin__title">Admin Dashboard</h1>
<div class="admin__grid">
<AdminStats class="admin__stats" />
<SystemStatusPanel class="admin__system-status" />
</div>
<div class="admin__torrents">
<TorrentManagementGrid />
</div>
<div class="admin__activity">
<RecentActivityFeed />
</div>
</section>
</template>
<script setup lang="ts">
import AdminStats from "@/components/admin/AdminStats.vue";
import TorrentManagementGrid from "@/components/admin/TorrentManagementGrid.vue";
import SystemStatusPanel from "@/components/admin/SystemStatusPanel.vue";
import RecentActivityFeed from "@/components/admin/RecentActivityFeed.vue";
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.admin {
padding: 3rem;
max-width: 100%;
overflow-x: hidden;
@include mobile-only {
padding: 0.75rem;
width: 100%;
box-sizing: border-box;
}
&__title {
margin: 0 0 2rem 0;
font-size: 2rem;
font-weight: 300;
color: $text-color;
line-height: 1;
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
@include mobile-only {
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
}
&__stats {
grid-column: 1;
min-width: 0;
@include mobile-only {
width: 100%;
}
}
&__system-status {
grid-column: 2;
min-width: 0;
@include mobile-only {
grid-column: 1;
width: 100%;
}
}
&__torrents {
margin-bottom: 1.5rem;
min-width: 0;
overflow-x: hidden;
@include mobile-only {
margin-bottom: 1rem;
width: 100%;
}
}
&__activity {
margin-bottom: 1.5rem;
min-width: 0;
@include mobile-only {
margin-bottom: 1rem;
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="password">
<h1 class="password__title">Password Generator</h1>
<div class="password__content">
<password-generator />
</div>
</div>
</template>
<script setup lang="ts">
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
function handleGeneratedPassword() {
return;
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password {
padding: 3rem;
max-width: 100%;
@include mobile-only {
padding: 0.75rem;
}
&__title {
margin: 0 0 2rem 0;
font-size: 2rem;
font-weight: 300;
color: $text-color;
line-height: 1;
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
}
&__content {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
}
</style>

View File

@@ -118,8 +118,8 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.button--group {
display: flex;

View File

@@ -131,7 +131,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "scss/variables";
section {
padding: 1.3rem;

View File

@@ -89,7 +89,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "scss/media-queries";
.filter {
margin-top: 0.5rem;

View File

@@ -1,21 +1,75 @@
<template>
<section class="settings">
<link-plex-account @reload="reloadSettings" />
<div class="settings__container">
<!-- Profile Hero Card -->
<div class="profile-hero">
<div class="profile-hero__main">
<div class="profile-hero__avatar">
<div class="avatar-large">{{ userInitials }}</div>
</div>
<div class="profile-hero__info">
<h1 class="profile-hero__name">{{ username }}</h1>
<span :class="['profile-hero__badge', `badge--${userRole}`]">
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
<span v-else>{{ userRole }}</span>
</span>
<p class="profile-hero__member">Member since {{ memberSince }}</p>
</div>
</div>
<hr class="setting__divider" />
<div class="profile-hero__stats">
<div class="stat-large">
<span class="stat-large__value">{{ stats.totalRequests }}</span>
<span class="stat-large__label">Requests</span>
</div>
<div class="stat-divider"></div>
<div class="stat-large">
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
<span class="stat-large__label">Magnets Added</span>
</div>
</div>
</div>
<change-password />
<!-- Settings Grid -->
<div class="settings__grid">
<!-- Left Column: Quick Settings -->
<div class="settings__column settings__column--left">
<section class="settings-section settings-section--compact">
<h2 class="section-header">Appearance</h2>
<theme-preferences />
</section>
<hr class="setting__divider" />
<section class="settings-section settings-section--compact">
<h2 class="section-header">Security</h2>
<change-password />
</section>
</div>
<!-- Right Column: Data-Heavy Sections -->
<div class="settings__column settings__column--right">
<section class="settings-section">
<h2 class="section-header">Integrations</h2>
<plex-settings @reload="reloadSettings" />
</section>
<section class="settings-section">
<h2 class="section-header">Data & Privacy</h2>
<data-export />
</section>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { inject, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import ThemePreferences from "@/components/settings/ThemePreferences.vue";
import PlexSettings from "@/components/settings/PlexSettings.vue";
import ChangePassword from "@/components/profile/ChangePassword.vue";
import LinkPlexAccount from "@/components/profile/LinkPlexAccount.vue";
import DataExport from "@/components/settings/DataExport.vue";
import { getSettings } from "../api";
const store = useStore();
@@ -24,6 +78,29 @@
error;
} = inject("notifications");
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => {
return username.value.slice(0, 2).toUpperCase();
});
const memberSince = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
});
});
const stats = {
totalRequests: 45,
magnetsAdded: 127
};
function displayWarningIfMissingPlexAccount() {
if (route.query?.missingPlexAccount === "true") {
notifications.error({
@@ -44,53 +121,270 @@
}
// Functions called on component load
displayWarningIfMissingPlexAccount();
onMounted(() => displayWarningIfMissingPlexAccount());
</script>
<style lang="scss">
@import "src/scss/variables";
@import "src/scss/media-queries";
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.settings {
padding: 3rem;
min-height: calc(100vh - var(--header-size));
padding: 2rem 1.5rem;
@include mobile-only {
padding: 1rem;
}
&__header {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
&__container {
max-width: 1400px;
margin: 0 auto;
@include mobile-only {
max-width: 100%;
}
}
&__grid {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 1.25rem;
margin-top: 1.25rem;
align-items: start;
@include mobile-only {
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
}
}
&__column {
display: flex;
flex-direction: column;
gap: 1.25rem;
@include mobile-only {
gap: 1rem;
}
&--left {
// Quick settings - lighter, more concise
}
&--right {
// Data-heavy sections
}
}
}
.profile-hero {
background-color: var(--background-color-secondary);
border-radius: 0.75rem;
padding: 1.5rem;
border: 1px solid var(--background-40);
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
@include mobile-only {
flex-direction: column;
padding: 1.5rem 1.25rem;
border-radius: 0.5rem;
text-align: center;
gap: 1rem;
}
&__main {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.75rem;
}
}
&__avatar {
flex-shrink: 0;
}
&__info {
display: block;
margin-bottom: 25px;
display: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;
width: 96%;
text-align: left;
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
span {
font-weight: 200;
size: 16px;
&__badge {
display: inline-block;
padding: 0.25rem 0.7rem;
border-radius: 2rem;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
width: fit-content;
@include mobile-only {
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
}
&.badge--admin {
background-color: var(--color-warning);
color: $black;
}
&.badge--user {
background-color: var(--background-40);
}
}
&__member {
margin: 0;
font-size: 0.85rem;
color: $text-color-70;
@include mobile-only {
font-size: 0.8rem;
}
}
&__stats {
display: flex;
align-items: center;
gap: 1.75rem;
padding-left: 1.75rem;
border-left: 1px solid var(--background-40);
@include mobile-only {
width: 100%;
padding: 1rem 0 0 0;
border-left: none;
border-top: 1px solid var(--background-40);
justify-content: center;
gap: 1.25rem;
}
}
}
a {
text-decoration: none;
.avatar-large {
width: 70px;
height: 70px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: $white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
@include mobile-only {
width: 80px;
height: 80px;
font-size: 2rem;
}
}
.stat-large {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--highlight-color);
line-height: 1;
@include mobile-only {
font-size: 1.75rem;
}
}
&__label {
font-size: 0.75rem;
color: $text-color-70;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
@include mobile-only {
font-size: 0.75rem;
}
}
}
.stat-divider {
width: 1px;
height: 45px;
background-color: var(--background-40);
@include mobile-only {
height: 45px;
}
}
.settings-section {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1.25rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 1rem;
}
&--compact {
// Tighter spacing for quick settings
.section-header {
font-size: 1.25rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
@include mobile-only {
font-size: 1.2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.6rem;
}
}
}
}
.section-header {
margin: 0 0 1rem 0;
font-size: 1.5rem;
font-weight: 600;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
font-size: 1.3rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
}
}
</style>

View File

@@ -112,7 +112,7 @@
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "scss/variables";
section {
padding: 1.3rem;

View File

@@ -1,28 +1,25 @@
<template>
<div>
<page-header title="Torrent search page" />
<div class="torrents">
<h1 class="torrents__title">Torrent Search</h1>
<section>
<div class="search-input-group">
<seasoned-input
v-model="query"
type="torrents"
placeholder="Search torrents"
@keydown.enter="setTorrentQuery"
/>
<seasoned-button @click="setTorrentQuery">Search</seasoned-button>
</div>
<div class="search-input-group">
<seasoned-input
v-model="query"
type="torrents"
placeholder="Search torrents"
@keydown.enter="setTorrentQuery"
/>
<seasoned-button @click="setTorrentQuery">Search</seasoned-button>
</div>
<active-torrents />
<active-torrents />
<TorrentList :query="torrentQuery" />
</section>
<TorrentList :query="torrentQuery" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import PageHeader from "@/components/PageHeader.vue";
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import TorrentList from "@/components/torrent/TorrentSearchResults.vue";
@@ -42,16 +39,44 @@
</script>
<style lang="scss" scoped>
section {
padding: 1.25rem;
@import "scss/variables";
@import "scss/media-queries";
.torrents {
padding: 3rem;
max-width: 100%;
@include mobile-only {
padding: 0.75rem;
}
&__title {
margin: 0 0 2rem 0;
font-size: 2rem;
font-weight: 300;
color: $text-color;
line-height: 1;
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
}
.search-input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
@include mobile-only {
flex-direction: column;
gap: 0.75rem;
}
button {
margin-left: 0.5rem;
@include mobile-only {
width: 100%;
}
}
}
}

View File

@@ -3,22 +3,19 @@
<transition name="slide">
<div v-if="show" class="toast" :class="type || 'info'" @click="clicked">
<div class="toast--content">
<div class="toast--icon">
<i v-if="image"
><img class="toast--icon-image" :src="image" alt="Toast icon"
/></i>
</div>
<div v-if="description" class="toast--text">
<span class="toast--text__title">{{ title }}</span>
<br /><span
class="toast--text__description"
v-html="description"
></span>
<span class="toast--text__description" v-html="description"></span>
</div>
<div v-else class="toast--text">
<span class="toast--text__title-large">{{ title }}</span>
</div>
<div class="toast--dismiss" @click.stop="dismiss">
<i class="fas fa-times"></i>
</div>
<div class="toast--dismiss" @click="dismiss">
<i class="fas fa-times"></i>
</div>
@@ -65,105 +62,161 @@
</script>
<style lang="scss" scoped>
// @import '@/scss/variables.scss';
/* ------------------------------
Transition
------------------------------ */
.slide-enter-active {
transition: all 0.3s ease;
.slide-enter-active,
.slide-leave-active {
transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.slide-enter,
.slide-enter-from,
.slide-leave-to {
transform: translateY(100vh);
transform: translateY(40px);
opacity: 0;
}
.slide-leave-active {
transition: all 2s ease;
}
.toast--icon-image {
height: 100%;
width: 100%;
max-height: 45px;
max-width: 45px;
}
/* ------------------------------
Toast
------------------------------ */
.toast {
position: fixed;
bottom: 0.5rem;
right: 1.25rem;
bottom: 1.25rem;
z-index: 1000;
cursor: pointer;
z-index: 100;
background-color: white;
border-radius: 3px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.17), 0 2px 4px 0 rgba(0, 0, 0, 0.08);
padding: 0.5rem;
margin: 1rem 2rem 1rem 0.71rem;
// max-width: calc(100% - 3rem);
min-width: 320px;
min-width: 340px;
max-width: 460px;
width: calc(100vw - 2rem);
// If small screen we have a min-width that is related to the screen size.
// else large screens we want a max-width that only uses the space in bottom right
padding: 1.1rem 1.25rem;
right: 0;
line-height: 22.5px;
border-radius: 16px;
/* System-based surface */
background: var(--background-color-secondary);
/* Subtle separation */
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
/* Clear state indicator */
border-left: 5px solid transparent;
/* Base text tone */
color: var(--text-color, #1f2937);
line-height: 1.5;
/* ------------------------------
Content Layout
------------------------------ */
&--content {
display: flex;
align-items: center;
}
&--icon {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
align-items: flex-start;
gap: 1rem;
}
&--text {
margin-left: 0.5rem;
// color: $bt-brown;
color: black;
word-wrap: break-word;
&__title {
text-transform: capitalize;
font-weight: 400;
&-large {
font-size: 2rem;
}
}
&__description {
font-weight: 300;
}
flex: 1;
display: flex;
flex-direction: column;
}
/* ------------------------------
Typography Hierarchy
------------------------------ */
/* Context label */
&--text__title {
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.3px;
text-transform: uppercase;
/* Softer than body but not faded */
color: color-mix(in srgb, currentColor 75%, transparent);
}
/* Primary message */
&--text__description {
margin-top: 0.3rem;
font-size: 0.98rem;
font-weight: 400;
line-height: 1.5;
color: currentColor;
}
&--text__title-large {
font-size: 1.15rem;
font-weight: 500;
}
/* ------------------------------
Dismiss Button
------------------------------ */
&--dismiss {
align-self: flex-end;
flex-shrink: 0;
img {
width: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
background: var(--background-color);
}
i {
font-size: 0.75rem;
}
}
/* ------------------------------
State Colors
------------------------------ */
&.success {
border-left: 6px solid #38c172;
border-left-color: #22c55e;
}
&.info {
border-left: 6px solid #ffd300;
border-left-color: #facc15;
}
&.warning {
border-left: 6px solid #f6993f;
border-left-color: #f97316;
}
&.error {
border-left: 6px solid #e3342f;
border-left-color: #ef4444;
}
&.simple {
border-left: unset;
border-left-color: transparent;
}
}
/* ------------------------------
Mobile
------------------------------ */
@media (max-width: 480px) {
.toast {
right: 1rem;
left: 1rem;
width: auto;
min-width: unset;
}
}
</style>

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
/* eslint-disable-next-line import/no-cycle */
/* eslint-disable-next-line import-x/no-cycle */
import store from "./store";
declare global {
@@ -10,7 +10,7 @@ declare global {
}
}
const routes: Array<RouteRecordRaw> = [
const routes: RouteRecordRaw[] = [
{
name: "home",
path: "/",
@@ -56,8 +56,7 @@ const routes: Array<RouteRecordRaw> = [
},
{
name: "signin",
path: "/signin",
alias: "/login",
path: "/login",
component: () => import("./pages/SigninPage.vue")
},
{
@@ -74,8 +73,19 @@ const routes: Array<RouteRecordRaw> = [
// }
// },
{
name: "404",
path: "/404",
name: "password-gen",
path: "/password",
component: () => import("./pages/GenPasswordPage.vue")
},
{
name: "admin",
path: "/admin",
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("./pages/404Page.vue")
}
// {
@@ -96,10 +106,24 @@ const router = createRouter({
});
const loggedIn = () => store.getters["user/loggedIn"];
const hasPlexAccount = () => store.getters["user/plexUserId"] !== null;
const hasPlexAccount = () => {
// Check Vuex store first
if (store.getters["user/plexUserId"] !== null) return true;
// Fallback to localStorage
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
const parsed = JSON.parse(userData);
return parsed.id !== null && parsed.id !== undefined;
} catch {
return false;
}
}
return false;
};
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
/* eslint-disable @typescript-eslint/no-explicit-any */
router.beforeEach(
(to: RouteLocationNormalized, from: RouteLocationNormalized, next: any) => {
store.dispatch("documentTitle/updateTitle", to.name);

View File

@@ -1,5 +1,5 @@
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
.filter {
margin: 1rem;

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