Compare commits

..

43 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
57 changed files with 3620 additions and 3930 deletions

View File

@@ -13,7 +13,9 @@
<!-- Popup that will show above existing rendered content -->
<popup />
<!-- Command Palette for quick navigation -->
<darkmode-toggle />
<!-- Command Palette -->
<command-palette />
</div>
</template>
@@ -23,6 +25,7 @@
import NavigationHeader from "@/components/header/NavigationHeader.vue";
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();
@@ -62,7 +65,6 @@
grid-column: 2 / 3;
width: calc(100% - var(--header-size));
grid-row: 2;
z-index: 5;
@include mobile {
grid-column: 1 / 3;

View File

@@ -413,20 +413,6 @@ const unlinkPlexAccount = async () => {
});
};
const plexRecentlyAddedInLibrary = async (id: number) => {
const url = new URL(`/api/v2/plex/recently_added/${id}`, API_HOSTNAME);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error fetch plex recently added`); // eslint-disable-line no-console
throw error;
});
};
// - - - User graphs - - -
const fetchGraphData = async (
@@ -557,7 +543,6 @@ const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
};
export {
API_HOSTNAME,
getMovie,
getShow,
getPerson,
@@ -574,7 +559,6 @@ export {
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
plexRecentlyAddedInLibrary,
register,
login,
logout,

View File

@@ -1,25 +1,9 @@
<template>
<li class="cast-card">
<a
class="cast-card__link"
role="button"
tabindex="0"
:aria-label="ariaLabel"
@click="openCastItem"
@keydown.enter="openCastItem"
>
<div class="cast-card__image-wrapper">
<img
class="cast-card__image"
:src="pictureUrl"
:alt="imageAltText"
loading="lazy"
/>
</div>
<div class="cast-card__content">
<p class="cast-card__name">{{ creditItem.name || creditItem.title }}</p>
<p v-if="metaText" class="cast-card__meta">{{ metaText }}</p>
</div>
<li class="card">
<a @click="openCastItem" @keydown.enter="openCastItem">
<img :src="pictureUrl" alt="Movie or person poster image" />
<p class="name">{{ creditItem.name || creditItem.title }}</p>
<p class="meta">{{ creditItem.character || creditItem.year }}</p>
</a>
</li>
</template>
@@ -49,139 +33,85 @@
return "/assets/no-image_small.svg";
});
const metaText = computed(() => {
if ("character" in props.creditItem && props.creditItem.character) {
return props.creditItem.character;
}
if ("job" in props.creditItem && props.creditItem.job) {
return props.creditItem.job;
}
if ("year" in props.creditItem && props.creditItem.year) {
return props.creditItem.year;
}
return "";
});
const imageAltText = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem) {
return `${name} as ${props.creditItem.character}`;
}
if ("job" in props.creditItem) {
return `${name}, ${props.creditItem.job}`;
}
return name ? `Poster for ${name}` : "No image available";
});
const ariaLabel = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem && props.creditItem.character) {
return `View ${name}, played ${props.creditItem.character}`;
}
if ("job" in props.creditItem && props.creditItem.job) {
return `View ${name}, ${props.creditItem.job}`;
}
return `View ${name}`;
});
function openCastItem() {
store.dispatch("popup/open", { ...props.creditItem });
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
<style lang="scss">
li a p:first-of-type {
padding-top: 10px;
}
.cast-card {
list-style: none;
margin: 0 10px 10px 0;
width: 150px;
flex-shrink: 0;
li.card p {
font-size: 1em;
padding: 0 10px;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
max-height: calc(10px + ((16px * var(--line-height)) * 3));
}
li.card {
margin: 10px;
margin-right: 4px;
padding-bottom: 10px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
min-width: 140px;
width: 140px;
background-color: var(--background-color-secondary);
color: var(--text-color);
transition: all 0.3s ease;
transform: scale(0.97) translateZ(0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:first-of-type {
margin-left: 0;
}
&:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
transform: scale(1.03);
}
.cast-card__link {
display: flex;
flex-direction: column;
height: 100%;
text-decoration: none;
color: inherit;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background-color: var(
--highlight-secondary,
var(--background-color-secondary)
);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover,
&:focus {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
outline: none;
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
}
}
.cast-card__image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
background: linear-gradient(
135deg,
var(--background-color) 0%,
var(--background-color-secondary) 100%
);
}
.cast-card__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cast-card__content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 60px;
}
.cast-card__name {
margin: 0;
font-size: 0.95rem;
.name {
font-weight: 500;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.cast-card__meta {
margin: 0;
font-size: 0.85rem;
font-weight: 400;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color-70));
.character {
font-size: 0.9em;
}
.meta {
font-size: 0.9em;
color: var(--text-color-70);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
// margin-top: auto;
max-height: calc((0.9em * var(--line-height)) * 1);
}
a {
display: block;
text-decoration: none;
height: 100%;
display: flex;
flex-direction: column;
}
img {
width: 100%;
height: auto;
max-height: 210px;
background-color: var(--background-color);
object-fit: cover;
}
}
</style>

View File

@@ -56,6 +56,12 @@
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
/*
|--------------------------------------------------------------------------
| Modern Color System
|--------------------------------------------------------------------------
*/
const graphTemplates = [
{
borderColor: "#6366F1",
@@ -71,6 +77,12 @@
}
];
/*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
@@ -78,6 +90,12 @@
if (graphInstance) graphInstance.destroy();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
@@ -128,6 +146,12 @@
};
}
/*
|--------------------------------------------------------------------------
| Chart Generator
|--------------------------------------------------------------------------
*/
function generateGraph() {
if (!graphCanvas.value) return;

View File

@@ -1,86 +0,0 @@
<template>
<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 watched</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes watched</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WatchStats } from "../../composables/useTautulliStats";
interface Props {
watchStats: WatchStats | null;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.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);
}
@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;
}
}
</style>

View File

@@ -1,101 +0,0 @@
<template>
<div v-if="topContent.length > 0" class="watch-history">
<h3 class="section-title">Last 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>
</template>
<script setup lang="ts">
interface TopContentItem {
title: string;
type: string;
plays: number;
duration: number;
}
interface Props {
topContent: TopContentItem[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.watch-history {
margin-top: 2rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.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);
}
</style>

View File

@@ -41,7 +41,7 @@
const signinNavigationIcon: INavigationIcon = {
title: "Signin",
route: "/login",
route: "/signin",
icon: IconProfileLock
};

View File

@@ -42,7 +42,8 @@
.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;

View File

@@ -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

@@ -9,9 +9,18 @@
</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" }}
<IconPlex v-if="!loading" class="plex-icon" />
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
@@ -21,7 +30,6 @@
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
import IconPlex from "@/icons/IconPlex.vue";
const emit = defineEmits<{
authSuccess: [token: string];
@@ -126,12 +134,10 @@
cursor: not-allowed;
}
.plex-icon {
svg {
flex-shrink: 0;
--size: 24px;
width: var(--size);
height: var(--size);
fill: currentColor;
width: 22px;
height: 22px;
}
}

View File

@@ -6,7 +6,7 @@
rel="noopener noreferrer"
class="plex-library-item"
>
<figure :class="`item-poster ${item.type}`">
<figure class="item-poster">
<img
v-if="item.poster"
:src="item.poster"
@@ -113,7 +113,7 @@
}
</script>
<style style="scss" scoped>
<style scoped>
.plex-library-item {
display: flex;
flex-direction: column;
@@ -144,10 +144,6 @@
overflow: hidden;
background: #333;
margin: 0;
&.music {
aspect-ratio: 1/1;
}
}
.poster-image {

View File

@@ -21,33 +21,19 @@
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{
formatNumber(details.total)
}}</span>
<span class="overview-value">{{ details.total }}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Seasons</span>
<span class="overview-value">{{
formatNumber(details?.childCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<div class="overview-stat" v-if="libraryType === 'shows'">
<span class="overview-label">Episodes</span>
<span class="overview-value">{{
formatNumber(details?.leafCount)
}}</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>
<span class="overview-value">{{ details.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{
convertSecondsToHumanReadable(details?.duration / 1000)
}}</span>
<span class="overview-value">{{ details.totalDuration }}</span>
</div>
</div>
@@ -56,12 +42,10 @@
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in recentlyAdded"
v-for="(item, index) in details.recentlyAdded"
:key="index"
:item="item"
:show-extras="
libraryType === 'music' || libraryType === 'tv shows'
"
:show-extras="libraryType === 'music' || libraryType === 'shows'"
/>
</div>
</div>
@@ -94,70 +78,41 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
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";
import { plexRecentlyAddedInLibrary } from "@/api";
import { processLibraryItem } from "@/utils/plexHelpers";
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
import { usePlexAuth } from "@/composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
const authToken = getPlexAuthCookie();
interface LibraryDetails {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
duration: number;
genres: Array<{
name: string;
count: number;
}>;
recentlyAdded: any[];
genres: { name: string; count: number }[];
totalDuration: string;
totalEpisodes?: number;
totalTracks?: number;
}
interface Props {
libraryType: string;
details: LibraryDetails;
serverUrl: string;
serverMachineId: string;
}
const props = defineProps<Props>();
let recentlyAdded = ref([]);
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
if (props.libraryType === "movies") return IconMovie;
if (props.libraryType === "tv shows") return IconShow;
if (props.libraryType === "shows") return IconShow;
if (props.libraryType === "music") return IconMusic;
return IconMovie;
});
function fetchRecentlyAdded() {
plexRecentlyAddedInLibrary(props.details.id).then(added => {
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
processLibraryItem(
el,
props.libraryType,
authToken,
props.serverUrl,
props.serverMachineId
)
);
});
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
@@ -165,18 +120,12 @@
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
fetchRecentlyAdded();
});
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style lang="scss" scoped>
@import "scss/media-queries.scss";
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
@@ -190,10 +139,6 @@
justify-content: center;
z-index: 1000;
padding: 20px;
@include mobile {
padding: 0;
}
}
.library-modal-content {
@@ -205,11 +150,6 @@
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@include mobile {
max-height: 100vh;
border-radius: unset;
}
}
.library-modal-header {
@@ -258,16 +198,12 @@
border: none;
color: #888;
cursor: pointer;
padding: 0.5rem;
padding: 8px;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
@include mobile {
margin: auto 0;
}
}
.close-btn:hover {

View File

@@ -5,23 +5,18 @@
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === undefined || stat.value === 0 || loading,
disabled: stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable &&
stat.value?.total > 0 &&
!loading &&
handleClick(stat.key)
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">
{{ formatNumber(stat.value?.total) }}
</div>
<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>
@@ -31,24 +26,15 @@
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from "@/utils";
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 LibraryStat {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
}
interface Props {
movies: LibraryStat;
shows: LibraryStat;
music: LibraryStat;
movies: number;
shows: number;
music: number;
watchtime: number;
loading?: boolean;
}
@@ -68,7 +54,7 @@
clickable: true
},
{
key: "tv shows",
key: "shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",

View File

@@ -3,14 +3,37 @@
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<IconServer class="label-icon" style="fill: var(--text-color)" />
Plex server name
<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">
<IconSync class="label-icon" style="stroke: var(--text-color)" />
<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>
@@ -19,7 +42,21 @@
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<IconSync v-if="!syncing" class="button-icon" />
<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')">
@@ -31,8 +68,6 @@
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconServer from "@/icons/IconServer.vue";
import IconSync from "@/icons/IconSync.vue";
interface Props {
serverName: string;
@@ -82,13 +117,9 @@
}
svg {
color: var(--text-color-60);
flex-shrink: 0;
}
.label-icon {
width: 16px;
height: 16px;
}
}
.detail-value {
@@ -116,11 +147,6 @@
svg {
flex-shrink: 0;
}
.button-icon {
width: 16px;
height: 16px;
}
}
}
</style>

View File

@@ -215,7 +215,7 @@
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_API = import.meta.env.VITE_SEASONED_COLORS_API || "";
const COLORS_URL = "https://colors.schleppe.cloud/colors";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
@@ -352,7 +352,7 @@
}
async function colorsFromPoster(posterPath: string) {
const url = new URL("/colors", COLORS_API);
const url = new URL(COLORS_URL);
url.searchParams.append("id", posterPath.replace("/", ""));
url.searchParams.append("size", "w342");
@@ -435,7 +435,7 @@
> img {
width: 100%;
border-radius: calc(1.6rem - 1px);
border-radius: inherit;
}
}
}

View File

@@ -1,7 +1,12 @@
<template>
<div class="change-password">
<div class="password-card">
<form class="password-form" @submit.prevent>
<p class="password-info">
Update your password to keep your account secure. Use a strong password
with at least 8 characters.
</p>
<form class="password-form" @submit.prevent="changePassword">
<seasoned-input
v-model="oldPassword"
placeholder="Current password"
@@ -67,8 +72,49 @@
newPasswordRepeat.value = password;
}
async function changePassword(event: CustomEvent) {
function addWarningMessage(message: string, title?: string) {
messages.value.push({
message,
title,
type: ErrorMessageTypes.Warning
} as IErrorMessage);
}
function validate() {
return;
return new Promise((resolve, reject) => {
if (!oldPassword.value || oldPassword?.value?.length === 0) {
addWarningMessage("Missing old password!", "Validation error");
reject();
}
if (!newPassword.value || newPassword?.value?.length === 0) {
addWarningMessage("Missing new password!", "Validation error");
reject();
}
if (newPassword.value !== newPasswordRepeat.value) {
addWarningMessage(
"Password and password repeat do not match!",
"Validation error"
);
reject();
}
resolve(true);
});
}
async function changePassword() {
try {
await validate();
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",
@@ -107,6 +153,20 @@
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;

View File

@@ -1,14 +1,106 @@
<template>
<div class="data-export">
<div class="export-options">
<!-- Request History Card -->
<RequestHistory :data="requestStats" />
<!-- 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 -->
<ExportSection :data="requestStats" />
<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 -->
<StorageManager />
<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
@@ -18,36 +110,748 @@
@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 } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue";
import RequestHistory from "./RequestHistory.vue";
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 *permanently delete* your account and all associated data? This action cannot be undone."
"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;
gap: 2rem;
}
.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

@@ -1,125 +0,0 @@
<template>
<div class="settings-section-card">
<div class="settings-section-header">
<h2>Export Your Data</h2>
<p>
Download a copy of your account data including requests, watch history,
and preferences.
</p>
</div>
<!-- Export to JSON & CSV section -->
<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>
</template>
<script setup lang="ts">
import { ref, defineProps } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Props {
data: any;
}
const props = defineProps<Props>();
const exporting = ref(false);
async function exportData(format: "json" | "csv") {
exporting.value = true;
// Mock export
await new Promise(resolve => setTimeout(resolve, 1500));
const data = {
username: "user123",
requests: props?.data,
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}`;
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.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;
}
}
}
</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

@@ -36,10 +36,7 @@
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Words</label>
<span class="slider-value">{{ wordCount }}</span>
</div>
<label>Number of words: {{ wordCount }}</label>
<input
v-model.number="wordCount"
type="range"
@@ -48,10 +45,7 @@
class="slider"
@input="generateWordsPassword"
/>
<div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
<span class="option-value">{{ wordCount }}</span>
</div>
</div>
</div>
@@ -76,10 +70,7 @@
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<label>Length: {{ charLength }}</label>
<input
v-model.number="charLength"
type="range"
@@ -88,10 +79,7 @@
class="slider"
@input="generateCharsPassword"
/>
<div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
<span class="option-value">{{ charLength }}</span>
</div>
<div class="option-row checkbox-row">
@@ -145,7 +133,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { ref, computed, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
@@ -372,12 +360,12 @@
.option-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.4rem;
label {
font-size: 0.95rem;
font-size: 0.85rem;
color: $text-color;
font-weight: 600;
font-weight: 500;
line-height: 1.2;
}
@@ -408,121 +396,38 @@
}
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: right;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: $text-color-50;
margin-top: 0.25rem;
padding: 0 0.25rem;
}
.slider {
width: 100%;
height: 10px;
border-radius: 5px;
height: 6px;
border-radius: 3px;
background: var(--background-40);
outline: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
margin: 0.5rem 0;
@include mobile-only {
height: 12px;
}
&:hover {
background: var(--background-40);
}
&::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
margin-top: -7px;
@include mobile-only {
width: 28px;
height: 28px;
margin-top: -8px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
cursor: pointer;
}
&::-moz-range-thumb {
width: 24px;
height: 24px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
@include mobile-only {
width: 28px;
height: 28px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
@include mobile-only {
height: 12px;
}
}
&::-moz-range-track {
height: 10px;
border-radius: 5px;
background: var(--background-40);
@include mobile-only {
height: 12px;
}
}
.option-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--highlight-color);
text-align: center;
}
.separator-input {

View File

@@ -2,7 +2,7 @@
<div class="plex-settings">
<!-- Unconnected state -->
<PlexAuthButton
v-if="!showPlexInformation"
v-if="!isPlexConnected"
@auth-success="handleAuthSuccess"
@auth-error="handleAuthError"
/>
@@ -16,20 +16,20 @@
/>
<PlexLibraryStats
:movies="libraryStats?.movies"
:shows="libraryStats?.['tv shows']"
:music="libraryStats?.music"
:watchtime="libraryStats?.watchtime || 0"
:loading="syncingLibrary"
:movies="libraryStats.movies"
:shows="libraryStats.shows"
:music="libraryStats.music"
:watchtime="libraryStats.watchtime"
:loading="loadingLibraries"
@open-library="showLibraryDetails"
/>
<PlexServerInfo
:serverName="plexServer"
:lastSync="lastSync"
:syncing="syncingServer"
:syncing="syncing"
@sync="syncLibrary"
@unlink="() => (showUnlinkModal = true)"
@unlink="confirmUnlink"
/>
</div>
@@ -38,18 +38,16 @@
<!-- Unlink Confirmation Modal -->
<PlexUnlinkModal
v-if="showUnlinkModal"
v-if="showConfirmModal"
@confirm="unauthenticatePlex"
@cancel="() => (showUnlinkModal = false)"
@cancel="cancelUnlink"
/>
<!-- Library Details Modal -->
<PlexLibraryModal
v-if="showLibraryModal && selectedLibrary"
:libraryType="selectedLibrary"
:details="libraryStats[selectedLibrary]"
:serverUrl="plexServerUrl"
:serverMachineId="plexMachineId"
:details="libraryDetails[selectedLibrary]"
@close="closeLibraryModal"
/>
</div>
@@ -57,6 +55,7 @@
<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";
@@ -65,167 +64,184 @@
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
import { usePlexAuth } from "@/composables/usePlexAuth";
import {
fetchPlexServers,
fetchPlexUserData,
fetchLibraryDetails
} from "@/composables/usePlexApi";
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 syncingServer = ref(false);
const syncingLibrary = ref(false);
const showUnlinkModal = ref(false);
const loading = ref(false);
const syncing = ref(false);
const showConfirmModal = ref(false);
const plexUsername = ref<string>("");
const plexUserData = ref<any>(null);
const showPlexInformation = ref<boolean>(false);
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(sessionStorage.getItem("plex_library_last_sync"));
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 { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
const { getCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
const {
fetchPlexUserData,
fetchPlexServers,
fetchLibrarySections,
fetchLibraryDetails
} = usePlexApi();
const { loadLibraries } = usePlexLibraries();
// ----- Connection check -----
function checkPlexConnection() {
const authToken = getPlexAuthCookie();
showPlexInformation.value = !!authToken;
return showPlexInformation.value;
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 loadPlexServer() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_server_data";
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
const server = JSON.parse(cachedData);
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
async function fetchPlexLibraries(authToken: string) {
try {
loadingLibraries.value = true;
const server = await fetchPlexServers(authToken);
if (!server) {
console.error("No Plex server found");
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
plexServer.value = server.name;
plexServerUrl.value = server.url;
plexMachineId.value = server.machineIdentifier;
lastSync.value = new Date().toLocaleString();
// make api call for data
syncingServer.value = true;
const server = await fetchPlexServers(authToken);
if (server) {
// set server name & id
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(server));
// set last-sync date
const now = new Date().toLocaleString();
lastSync.value = now;
sessionStorage.setItem("plex_library_last_sync", now);
} else {
console.log("unable to load plex server informmation");
const sections = await fetchLibrarySections(authToken, server.url);
if (!sections || sections.length === 0) {
console.error("No library sections found");
return;
}
syncingServer.value = false;
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() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_user_data";
const cachedData = sessionStorage.getItem(cacheKey);
checkPlexConnection();
const cachedData = localStorage.getItem("plex_user_data");
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
try {
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
return;
isPlexConnected.value = true;
} catch (error) {
console.error("[PlexSettings] Error parsing cached Plex data:", error);
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
}
const authToken = getCookie("plex_auth_token");
hasCookieData.value = !!authToken;
if (authToken) {
const userData = await fetchPlexUserData(authToken);
if (userData) {
// set plex user data
plexUserData.value = userData;
plexUsername.value = userData?.username;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
plexUsername.value = userData.username;
isPlexConnected.value = true;
} else if (!cachedData) {
isPlexConnected.value = false;
}
if (isPlexConnected.value) {
await fetchPlexLibraries(authToken);
}
} else {
console.log("unable to load user data from plex");
isPlexConnected.value = false;
}
}
// ----- Load plex libary details -----
async function loadPlexLibraries() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_library_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
libraryStats.value = JSON.parse(cachedData);
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
syncingLibrary.value = true;
const library = await fetchLibraryDetails();
if (library) {
libraryStats.value = library;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(library));
} else {
console.log("unable to load plex library details");
}
syncingLibrary.value = false;
}
// ----- OAuth flow (handlers for PlexAuthButton events) -----
async function handleAuthSuccess(authToken: string) {
try {
setPlexAuthCookie(authToken);
checkPlexConnection();
const success = await loadAll();
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: "Successfully connected your Plex account"
message: message || "Successfully connected your Plex account"
} as IErrorMessage);
} else {
console.error("[PlexSettings] Error in handleAuthSuccess:");
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",
@@ -243,24 +259,35 @@
}
// ----- Unlink flow -----
function confirmUnlink() {
showConfirmModal.value = true;
}
function cancelUnlink() {
showConfirmModal.value = false;
}
async function unauthenticatePlex() {
showUnlinkModal.value = false;
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
sessionStorage.removeItem("plex_library_last_sync");
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 = "";
showPlexInformation.value = false;
isPlexConnected.value = false;
emit("reload");
}
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Unlinked Plex account",
message: "All browser storage has been clear of plex account"
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 -----
@@ -277,60 +304,39 @@
// ----- Sync -----
async function syncLibrary() {
const authToken = getPlexAuthCookie();
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;
}
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
const success = await loadAll();
if (success) {
try {
await fetchPlexLibraries(authToken);
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Library synced",
message: "Your Plex library has been successfully synced"
} as IErrorMessage);
} else {
} 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;
}
}
// ---- Helper load all ----
async function loadAll() {
let success = false;
try {
await Promise.all([
loadPlexServer(),
loadPlexUserData(),
loadPlexLibraries()
]);
success = true;
} catch (error) {
console.log("loadall error, some info might be missing");
}
onMounted(() => {
checkPlexConnection();
return success;
}
// ---- Lifecycle functions ----
onMounted(loadAll);
loadPlexUserData();
});
onUnmounted(() => {
cleanup();
});

View File

@@ -1,233 +0,0 @@
<template>
<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>
<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>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
const store = useStore();
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
};
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.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: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
&__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: var(--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;
}
}
}
.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: var(--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;
}
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div class="export-card">
<div class="settings-section-header">
<h2>Request History</h2>
<p>View and download your complete request history.</p>
</div>
<div class="stats-grid">
<div class="stat-mini">
<span class="stat-mini__value">{{ data.total }}</span>
<span class="stat-mini__label">Total</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.approved }}</span>
<span class="stat-mini__label">Approved</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.pending }}</span>
<span class="stat-mini__label">Pending</span>
</div>
</div>
<button class="view-btn" @click="viewHistory">View Full History</button>
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import { useRouter } from "vue-router";
interface Props {
data: any;
}
defineProps<Props>();
const router = useRouter();
function viewHistory() {
router.push({ name: "profile" });
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.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;
color: var(--text-color);
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
</style>

View File

@@ -1,46 +0,0 @@
<template>
<div class="security-settings">
<div class="security-settings__intro">
<h2 class="security-settings__title">Security</h2>
<p class="security-settings__description">
Keep your account safe by using a strong, unique password. We recommend
using a passphrase or generated password that's hard to guess.
</p>
</div>
<change-password />
</div>
</template>
<script setup lang="ts">
import ChangePassword from "@/components/profile/ChangePassword.vue";
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.security-settings {
&__intro {
margin-bottom: 1rem;
@include mobile-only {
margin-bottom: 0.85rem;
}
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.3;
}
&__description {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-color-70);
}
}
</style>

View File

@@ -1,215 +0,0 @@
<template>
<div class="storage-manager">
<StorageSectionBrowser
:sections="storageSections"
@clear-item="clearItem"
/>
<DangerZoneAction
title="Clear All Browser Data"
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 { computed, inject } from "vue";
import IconCookie from "@/icons/IconCookie.vue";
import IconDatabase from "@/icons/IconDatabase.vue";
import IconTimer from "@/icons/IconTimer.vue";
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
import { formatBytes } from "../../utils";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
const notifications: {
success: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
error: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
} = inject("notifications");
const dict = {
commandPalette_stats: "Usage statistics for command palette navigation",
"theme-preference": "Your selected color theme",
plex_user_data: "Cached Plex account information",
plex_library_data: "Cached Plex library details per section",
plex_server_data: "Cached Plex server information",
plex_library_last_sync: "UTC time string for last synced Plex data",
plex_auth_token: "Authorized token from Plex.tv",
authorization: "This sites user login token"
};
const storageItems = computed<StorageItem[]>(() => {
const items: StorageItem[] = [];
// local storage
Object.keys(localStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(localStorage[key]?.length || 0),
type: "local"
});
});
// session storage
Object.keys(sessionStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(sessionStorage[key]?.length || 0),
type: "session"
});
});
// cookies
if (document.cookie) {
document.cookie.split(";").forEach(cookie => {
const [key, _] = cookie.trim().split("=");
if (key) {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(cookie.length || 0),
type: "cookie"
});
}
});
}
return items;
});
const getTotalSize = (items: StorageItem[]) => {
const totalBytes = items.reduce((acc, item) => {
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
if (!match) return acc;
const value = parseFloat(match[1]);
const unit = match[2];
return (
acc +
(unit === "KB"
? value * 1024
: unit === "MB"
? value * 1024 * 1024
: value)
);
}, 0);
return formatBytes(totalBytes);
};
const storageSections = computed(() => [
{
type: "local" as const,
title: "LocalStorage",
iconComponent: IconDatabase,
description:
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
items: storageItems.value.filter(item => item.type === "local"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "session" as const,
title: "SessionStorage",
iconComponent: IconTimer,
description:
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
items: storageItems.value.filter(item => item.type === "session"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "cookie" as const,
title: "Cookies",
iconComponent: IconCookie,
description:
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
items: storageItems.value.filter(item => item.type === "cookie"),
get totalSize() {
return getTotalSize(this.items);
}
}
]);
function clearItem(key: string, type: "local" | "session" | "cookie") {
try {
if (type === "local") {
localStorage.removeItem(key);
} else if (type === "session") {
sessionStorage.removeItem(key);
} else if (type === "cookie") {
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
notifications.success({
title: "Data Cleared",
description: `${key} has been cleared`,
timeout: 3000
});
// Force re-render
storageItems.value;
} catch (error) {
notifications.error({
title: "Error",
description: `Failed to clear ${key}`,
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();
sessionStorage.clear();
document.cookie.split(";").forEach(cookie => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
});
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>
.storage-manager {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,365 +0,0 @@
<template>
<div class="browser-storage">
<div class="settings-section-header">
<h2>Browser Storage</h2>
<p>
Your browser stores data locally to make this site faster and remember
your settings. View what's saved on this device and remove items
anytime.
</p>
</div>
<div class="storage-sections">
<div
v-for="section in sections"
:key="section.type"
:class="`storage-section storage-section--${section.type}`"
>
<button
class="storage-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="storage-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="storage-section__title">{{ section.title }}</h3>
<span class="storage-section__count">{{
section.items.length
}}</span>
<span class="storage-section__size">{{ section.totalSize }}</span>
</div>
<svg
class="storage-section__chevron"
:class="{
'storage-section__chevron--expanded':
expandedSections[section.type]
}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
v-if="expandedSections[section.type]"
class="storage-section__content"
>
<p class="storage-section__description">{{ section.description }}</p>
<div class="storage-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`storage-item storage-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="storage-item__info">
<h4 class="storage-item__title">{{ item.key }}</h4>
<p class="storage-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="storage-item__size">{{ item.size }}</span>
</p>
</div>
<button
class="storage-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Clear ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
interface StorageSection {
type: "local" | "session" | "cookie";
title: string;
description: string;
iconComponent: any;
items: StorageItem[];
totalSize: string;
}
defineProps<{
sections: StorageSection[];
}>();
defineEmits<{
"clear-item": [key: string, type: "local" | "session" | "cookie"];
}>();
const expandedSections = ref<Record<string, boolean>>({
local: false,
session: false,
cookie: false
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/shared-settings";
.browser-storage {
&__intro {
margin-bottom: 2rem;
}
}
.storage-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.storage-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--local {
border-color: rgba(139, 92, 246, 0.2);
.section-icon,
.type-icon {
stroke: #8b5cf6;
}
}
&--session {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
stroke: #f59e0b;
}
}
&--cookie {
border-color: rgba(236, 72, 153, 0.2);
.section-icon,
.type-icon {
fill: #ec4899;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
width: 20px;
height: 20px;
stroke: var(--text-color-70);
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.storage-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.storage-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--local {
border-color: #8b5cf6;
background: linear-gradient(
90deg,
rgba(139, 92, 246, 0.1),
var(--background-color)
);
}
&--session {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&--cookie {
border-color: #ec4899;
background: linear-gradient(
90deg,
rgba(236, 72, 153, 0.1),
var(--background-color)
);
}
&:hover .storage-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
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 {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@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

@@ -1,462 +0,0 @@
<template>
<div class="server-storage">
<div class="server-storage__intro">
<h2 class="server-storage__title">Server Storage</h2>
<p class="server-storage__description">
Data stored on our servers to sync across your devices and provide
personalized features.
</p>
</div>
<div class="server-sections">
<div
v-for="section in serverSections"
:key="section.type"
:class="`server-section server-section--${section.type}`"
>
<button
class="server-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="server-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="server-section__title">{{ section.title }}</h3>
<span class="server-section__count">{{
section.items.length
}}</span>
<span class="server-section__size">{{ section.totalSize }}</span>
</div>
<svg
class="server-section__chevron"
:class="{
'server-section__chevron--expanded':
expandedSections[section.type]
}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
v-if="expandedSections[section.type]"
class="server-section__content"
>
<p class="server-section__description">{{ section.description }}</p>
<div class="server-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`server-item server-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="server-item__info">
<h4 class="server-item__title">{{ item.key }}</h4>
<p class="server-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="server-item__size">{{ item.size }}</span>
<span v-if="item.lastSynced" class="server-item__synced">
· Last synced: {{ item.lastSynced }}</span
>
</p>
</div>
<button
class="server-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Delete ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface ServerItem {
key: string;
description: string;
size: string;
lastSynced?: string;
}
defineEmits<{
"clear-item": [key: string, type: string];
}>();
const expandedSections = ref<Record<string, boolean>>({
profile: false,
preferences: false,
activity: false
});
// Mock server data
const serverSections = computed(() => [
{
type: "profile",
title: "Profile Data",
iconComponent: IconProfile,
description:
"Your account information, settings, and preferences stored on our servers.",
items: [
{
key: "user_profile",
description: "User account details",
size: "2.4 KB",
lastSynced: "2 hours ago"
},
{
key: "avatar_image",
description: "Profile picture",
size: "145 KB",
lastSynced: "1 day ago"
},
{
key: "email_preferences",
description: "Notification settings",
size: "512 Bytes",
lastSynced: "3 days ago"
}
],
totalSize: "147.9 KB"
},
{
type: "preferences",
title: "Synced Preferences",
iconComponent: IconSettings,
description:
"Settings that sync across all your devices when you sign in.",
items: [
{
key: "theme_settings",
description: "Color theme and appearance",
size: "1.1 KB",
lastSynced: "5 hours ago"
},
{
key: "playback_settings",
description: "Video and audio preferences",
size: "856 Bytes",
lastSynced: "1 day ago"
},
{
key: "library_filters",
description: "Saved filters and sorting",
size: "2.3 KB",
lastSynced: "2 days ago"
}
],
totalSize: "4.3 KB"
},
{
type: "activity",
title: "Activity History",
iconComponent: IconActivity,
description:
"Your viewing history and watch progress stored on our servers.",
items: [
{
key: "watch_history",
description: "Recently watched items",
size: "12.5 KB",
lastSynced: "1 hour ago"
},
{
key: "watch_progress",
description: "Playback positions",
size: "8.2 KB",
lastSynced: "30 minutes ago"
},
{
key: "favorites",
description: "Starred and favorited content",
size: "3.7 KB",
lastSynced: "6 hours ago"
}
],
totalSize: "24.4 KB"
}
]);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.server-storage {
&__intro {
margin-bottom: 2rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
}
&__description {
margin: 0;
color: var(--text-color-70);
font-size: 0.95rem;
line-height: 1.6;
}
}
.server-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.server-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--profile {
border-color: rgba(59, 130, 246, 0.2);
.section-icon,
.type-icon {
fill: #3b82f6;
}
}
&--preferences {
border-color: rgba(16, 185, 129, 0.2);
.section-icon,
.type-icon {
fill: #10b981;
}
}
&--activity {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
fill: #f59e0b;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
width: 20px;
height: 20px;
stroke: var(--text-color-70);
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.server-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.server-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--profile {
border-color: #3b82f6;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.1),
var(--background-color)
);
}
&--preferences {
border-color: #10b981;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0.1),
var(--background-color)
);
}
&--activity {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&:hover .server-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
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;
}
&__synced {
color: var(--text-color-50);
font-style: italic;
}
&__delete {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@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

@@ -16,7 +16,10 @@
<button
v-for="theme in themes"
:key="theme.value"
:class="['theme-card', { active: selectedTheme === theme.value }]"
:class="[
'theme-card',
{ 'theme-card--active': selectedTheme === theme.value }
]"
@click="selectTheme(theme.value)"
>
<div class="theme-card__preview" :data-theme="theme.value">
@@ -32,31 +35,65 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useTheme } from "@/composables/useTheme";
import { ref, computed, onMounted } from "vue";
const themes = [
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" }
] as const;
];
const { currentTheme, savedTheme, setTheme } = useTheme();
const selectedTheme = currentTheme;
const selectedTheme = ref("auto");
const currentThemeName = computed(
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "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) {
setTheme(theme as any);
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(() => {
selectedTheme.value = savedTheme.value;
// Load saved preference or default to auto
const savedTheme = localStorage.getItem("theme-preference") || "auto";
selectTheme(savedTheme);
});
</script>
@@ -76,6 +113,7 @@
padding: 1.5rem;
border-radius: 0.75rem;
}
}
.theme-display {
display: flex;
@@ -104,32 +142,40 @@
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid;
}
&[data-theme="light"] .icon-inner {
background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
border-color: #01d277;
background: linear-gradient(135deg, #f8f8f8 0%, #e8e8e8 100%);
border: 3px solid #01d277;
}
&[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
border-color: #01d277;
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
border: 3px solid #01d277;
}
&[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027, #2c5364);
border-color: #00d4ff;
background: linear-gradient(135deg, #0f2027 0%, #2c5364 100%);
border: 3px solid #00d4ff;
}
&[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
border-color: #3d6e4e;
background: linear-gradient(135deg, #f5f0e8 0%, #d8cdb9 100%);
border: 3px solid #3d6e4e;
}
&[data-theme="halloween"] .icon-inner {
background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
border-color: #ff6600;
background: linear-gradient(135deg, #1a0e2e 0%, #2d1b3d 100%);
border: 3px solid #ff6600;
}
&[data-theme="auto"] .icon-inner {
background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
border-color: #01d277;
background: conic-gradient(
from 0deg,
#f8f8f8 0deg 180deg,
#1a1a1a 180deg 360deg
);
border: 3px solid #01d277;
}
}
@@ -140,7 +186,7 @@
gap: 0.35rem;
}
span {
.theme-label {
font-size: 0.85rem;
color: $text-color-70;
text-transform: uppercase;
@@ -152,7 +198,7 @@
}
}
h3 {
.theme-name {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
@@ -162,7 +208,6 @@
font-size: 1.4rem;
}
}
}
.theme-grid {
display: grid;
@@ -173,6 +218,7 @@
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.theme-card {
display: flex;
@@ -189,12 +235,6 @@
@include mobile-only {
padding: 0.85rem;
border-radius: 0.5rem;
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
}
&:hover {
@@ -203,12 +243,22 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&.active {
&--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;
@@ -225,11 +275,20 @@
.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;
border-radius: 50%;
background: #01d277;
}
&::before {
@@ -239,85 +298,140 @@
left: 8px;
right: 8px;
height: 20px;
background: #ffffff;
border-radius: 4px;
border: 1px solid;
}
}
&__preview[data-theme="light"] {
background: #f8f8f8;
.preview-circle {
background: #01d277;
}
&::before {
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
border: 1px solid rgba(8, 28, 36, 0.1);
}
}
// Dark Theme Preview
&__preview[data-theme="dark"] {
background: #111;
background: #111111;
.preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #01d277;
}
&::before {
background: #060708;
border-color: rgba(255, 255, 255, 0.1);
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-color: rgba(0, 212, 255, 0.2);
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-color: rgba(61, 110, 78, 0.2);
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-color: rgba(255, 102, 0, 0.2);
border-radius: 4px;
border: 1px solid rgba(255, 102, 0, 0.2);
}
}
// Auto Theme Preview (split)
&__preview[data-theme="auto"] {
border-color: black;
background: linear-gradient(
135deg,
#f8f8f8 0%,
#f8f8f8 50%,
#111 50%,
#111 100%
#111111 50%,
#111111 100%
);
.preview-circle {
left: auto;
bottom: 8px;
right: 8px;
width: 30px;
height: 30px;
background: #01d277;
}
&::before {
right: auto;
content: "";
position: absolute;
top: 8px;
left: 8px;
width: calc(50% - 10px);
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
height: 20px;
background: #ffffff;
border-radius: 4px;
border: 1px solid rgba(8, 28, 36, 0.1);
}
}
@@ -326,6 +440,7 @@
font-weight: 600;
line-height: 1;
color: var(--text-color);
@include mobile-only {
font-size: 0.85rem;
}
@@ -337,7 +452,7 @@
right: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--highlight-color);
color: white;
color: $white;
border-radius: 1rem;
font-size: 0.65rem;
font-weight: 600;
@@ -351,5 +466,4 @@
}
}
}
}
</style>

View File

@@ -1,39 +1,23 @@
<template>
<div class="torrent-table">
<div class="sort-toggle">
<span class="sort-label">Sort by:</span>
<div class="sort-options">
<button
v-for="option in sortOptions"
:key="option.value"
:class="['sort-btn', { active: selectedSort === option.value }]"
@click="changeSort(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<table>
<thead class="table__header noselect">
<tr>
<th
class="name-header"
:class="selectedSort === 'name' ? 'active' : null"
@click="changeSort('name')"
v-for="column in visibleColumns"
:key="column"
:class="column === selectedColumn ? 'active' : null"
@click="sortTable(column)"
>
Name
<span v-if="selectedSort === 'name'">{{
direction ? "" : ""
}}</span>
{{ column }}
<span v-if="prevCol === column && direction"></span>
<span v-if="prevCol === column && !direction"></span>
</th>
<th class="add-header">Add</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in sortedTorrents"
v-for="torrent in torrents"
:key="torrent.magnet"
class="table__content"
>
@@ -43,12 +27,28 @@
@keydown.enter="expand($event, torrent.name)"
>
<div class="torrent-title">{{ torrent.name }}</div>
<div class="torrent-meta">
<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)"
>
{{ torrent.size }}
</td>
<td
class="download"
@click="() => emit('magnet', torrent)"
@@ -59,11 +59,10 @@
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconMagnet from "@/icons/IconMagnet.vue";
import type { Ref } from "vue";
import { sortableSize } from "../../utils";
@@ -80,55 +79,31 @@
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const sortOptions = [
{ value: "name", label: "Name" },
{ value: "size", label: "Size" },
{ value: "seed", label: "Seeders" }
];
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 selectedSort: Ref<string> = ref("size");
const prevSort: Ref<string> = ref("");
const selectedColumn: Ref<string> = ref(columns[0]);
const prevCol: Ref<string> = ref("");
const sortedTorrents = computed(() => {
const sorted = [...torrents.value];
if (selectedSort.value === "name") {
sorted.sort((a, b) =>
direction.value
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
);
} else if (selectedSort.value === "size") {
sorted.sort((a, b) =>
direction.value
? sortableSize(a.size) - sortableSize(b.size)
: sortableSize(b.size) - sortableSize(a.size)
);
} else if (selectedSort.value === "seed") {
sorted.sort((a, b) =>
direction.value
? parseInt(a.seed, 10) - parseInt(b.seed, 10)
: parseInt(b.seed, 10) - parseInt(a.seed, 10)
);
function handleResize() {
windowWidth.value = window.innerWidth;
}
return sorted;
onMounted(() => {
window.addEventListener("resize", handleResize);
});
function changeSort(sortBy: string) {
if (prevSort.value === sortBy) {
direction.value = !direction.value;
} else {
direction.value = false;
selectedSort.value = sortBy;
}
prevSort.value = sortBy;
}
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
function expand(event: MouseEvent, text: string) {
return;
const elementClicked = event.target as HTMLElement;
const tableRow = elementClicked.parentElement;
const scopedStyleDataVariable = Object.keys(tableRow.dataset)[0];
@@ -141,6 +116,8 @@
if (existingExpandedElement) {
existingExpandedElement.remove();
// Clicked the same element twice, remove and return
// not recreate and collapse
if (clickedSameTwice) return;
}
@@ -151,11 +128,59 @@
expandedRow.className = "expanded";
expandedCol.innerText = text;
expandedCol.colSpan = 2;
// 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);
}
function sortName() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1));
} else {
torrents.value = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1));
}
}
function sortSeed() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort(
(a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10)
);
} else {
torrents.value = torrentsCopy.sort(
(a, b) => parseInt(b.seed, 10) - parseInt(a.seed, 10)
);
}
}
function sortSize() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort((a, b) =>
sortableSize(a.size) > sortableSize(b.size) ? 1 : -1
);
} else {
torrents.value = torrentsCopy.sort((a, b) =>
sortableSize(a.size) < sortableSize(b.size) ? 1 : -1
);
}
}
function sortTable(col, sameDirection = false) {
if (prevCol.value === col && sameDirection === false) {
direction.value = !direction.value;
}
if (col === "name") sortName();
else if (col === "seed") sortSeed();
else if (col === "size") sortSize();
prevCol.value = col;
}
</script>
<style lang="scss" scoped>
@@ -163,58 +188,6 @@
@import "scss/media-queries";
@import "scss/elements";
.torrent-table {
width: 100%;
}
.sort-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
.sort-label {
font-size: 0.85rem;
color: var(--text-color-70);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sort-options {
display: flex;
gap: 0.25rem;
}
.sort-btn {
border: 1px solid var(--highlight-bg, var(--background-color-40));
color: var(--text-color-70);
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
&:hover {
background: var(--highlight-bg, var(--background-color));
color: var(--text-color);
}
&.active {
background: var(--highlight-color);
color: var(--text-color);
border-color: var(--highlight-color, $green);
}
@include mobile {
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
}
}
}
table {
border-spacing: 0;
margin-top: 0.5rem;
@@ -222,11 +195,16 @@
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;
@@ -239,16 +217,16 @@
position: relative;
user-select: none;
-webkit-user-select: none;
color: var(--highlight-bg, var(--table-header-text-color));
color: var(--table-header-text-color);
text-transform: uppercase;
cursor: pointer;
background-color: var(--highlight-color, var(--highlight-color));
background-color: var(--table-background-color);
background-color: var(--highlight-color);
letter-spacing: 0.8px;
font-size: 1rem;
th:last-of-type {
padding: 0 0.4rem;
border-left: 1px solid var(--highlight-bg, var(--background-color));
padding-right: 0.4rem;
}
}
@@ -259,7 +237,7 @@
padding: 0.5rem 0.6rem;
cursor: default;
word-break: break-word;
border-left: 1px solid var(--highlight-secondary, var(--highlight-color));
border-left: 1px solid var(--table-background-color);
@include mobile {
width: 100%;
@@ -280,8 +258,8 @@
.torrent-meta {
font-size: 0.85rem;
color: var(--text-color-60);
display: flex;
opacity: 70%;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
@@ -297,12 +275,20 @@
}
}
// seed and size columns (desktop only)
.torrent-seed,
.torrent-size {
text-align: center;
white-space: nowrap;
padding: 0.5rem;
}
// last column - action
tr td:last-of-type {
vertical-align: middle;
cursor: pointer;
border-right: 1px solid var(--highlight-secondary, var(--highlight-color));
max-width: 60px;
border-right: 1px solid var(--table-background-color);
width: 60px;
text-align: center;
@include mobile {
@@ -314,7 +300,7 @@
display: block;
margin: auto;
padding: 0.3rem 0;
fill: var(inherit, var(--text-color));
fill: var(--text-color);
@include mobile {
width: 18px;
@@ -324,30 +310,16 @@
// alternate background color per row
tr {
background-color: var(--highlight-bg, var(--background-90));
color: var(--text-color);
td {
border-left: 1px solid
var(--highlight-secondary, var(--highlight-color));
fill: var(--text-color);
}
}
tr:nth-child(odd) {
background-color: var(--highlight-secondary, var(--background-color));
color: var(--highlight-bg, var(--text-color));
td {
fill: var(--highlight-bg, var(--text-color)) !important;
background-color: var(--background-color);
}
tr:nth-child(even) {
background-color: var(--background-70);
}
// last element rounded corner border
tr:last-of-type {
td {
border-bottom: 1px solid
var(--highlight-secondary, var(--highlight-color));
border-left: 1px solid var(--highlight-bg, var(--text-color));
border-bottom: 1px solid var(--table-background-color);
}
td:first-of-type {
@@ -363,16 +335,15 @@
.expanded {
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid var(--text-color);
border-right: 1px solid var(--text-color);
border-bottom: 1px solid var(--text-color);
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
td {
white-space: normal;
word-break: break-all;
padding: 0.5rem 0.15rem;
width: 100%;
color: var(--text-color);
}
}
</style>

View File

@@ -139,6 +139,7 @@
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";

View File

@@ -0,0 +1,48 @@
<template>
<div class="darkToggle">
<span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{
darkmodeToggleIcon
}}</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
const darkmode = ref(systemDarkModeEnabled());
const darkmodeToggleIcon = computed(() => {
return darkmode.value ? "🌝" : "🌚";
});
function toggleDarkmode() {
darkmode.value = !darkmode.value;
document.body.className = darkmode.value ? "dark" : "light";
}
</script>
<style lang="scss" scoped>
.darkToggle {
height: 25px;
width: 25px;
cursor: pointer;
position: fixed;
margin-bottom: 1.5rem;
margin-right: 2px;
bottom: 0;
right: 0;
z-index: 10;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

View File

@@ -2,7 +2,7 @@
<button
type="button"
:class="{ active: active, fullwidth: fullWidth }"
@click="event => emit('click', event)"
@click="emit('click')"
>
<slot></slot>
</button>
@@ -15,7 +15,7 @@
}
interface Emit {
(e: "click", event?: MouseEvent);
(e: "click");
}
defineProps<Props>();

View File

@@ -1,57 +1,16 @@
import { ref } from "vue";
import { API_HOSTNAME } from "../api";
// 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;
async function fetchPlexServers(authToken: string) {
try {
const url =
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
const options = {
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
};
const response = await fetch(url, options);
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];
return {
name: ownedServer.name,
url: connection?.uri,
machineIdentifier: ownedServer.clientIdentifier
};
}
return null;
} catch (error) {
console.error("[PlexAPI] Error fetching Plex servers:", error);
return null;
}
}
export function usePlexApi() {
const plexServerUrl = ref("");
// Fetch Plex user data
async function fetchPlexUserData(authToken: string) {
try {
const url = "https://plex.tv/api/v2/user";
const options = {
const response = await fetch("https://plex.tv/api/v2/user", {
method: "GET",
headers: {
accept: "application/json",
@@ -59,9 +18,7 @@ async function fetchPlexUserData(authToken: string) {
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
"X-Plex-Token": authToken
}
};
const response = await fetch(url, options);
});
if (!response.ok) {
throw new Error("Failed to fetch Plex user info");
@@ -103,6 +60,7 @@ async function fetchPlexUserData(authToken: string) {
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);
@@ -110,16 +68,133 @@ async function fetchPlexUserData(authToken: string) {
}
}
// Fetch library details
async function fetchLibraryDetails() {
// Fetch Plex servers
async function fetchPlexServers(authToken: string) {
try {
const url = `${API_HOSTNAME}/api/v2/plex/library`;
const options: RequestInit = { credentials: "include" };
return await fetch(url, options).then(resp => resp.json());
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 library:", error);
console.error("[PlexAPI] Error fetching Plex servers:", error);
return null;
}
}
export { fetchPlexServers, fetchPlexUserData, fetchLibraryDetails };
// 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

@@ -9,17 +9,15 @@ export function usePlexAuth() {
// Generate a PIN for Plex OAuth
async function generatePlexPin() {
try {
const url = "https://plex.tv/api/v2/pins?strong=true";
const options = {
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
}
};
});
const response = await fetch(url, options);
if (!response.ok) throw new Error("Failed to generate PIN");
const data = await response.json();
return { id: data.id, code: data.code };
@@ -32,15 +30,15 @@ export function usePlexAuth() {
// Check PIN status
async function checkPin(pinId: number, pinCode: string) {
try {
const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
const options = {
const response = await fetch(
`https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
{
headers: {
accept: "application/json",
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
};
const response = await fetch(url, options);
}
);
if (!response.ok) return null;
const data = await response.json();
@@ -95,10 +93,9 @@ export function usePlexAuth() {
}
// Get cookie
function getPlexAuthCookie(): string | null {
const key = "plex_auth_token";
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${key}=`);
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
@@ -174,10 +171,9 @@ export function usePlexAuth() {
if (plexPopup.value && plexPopup.value.closed) {
clearInterval(popupChecker);
stopPolling();
if (loading.value) {
loading.value = false;
// onError("Plex authentication window was closed");
onError("Plex authentication window was closed");
}
}
}, 500);
@@ -194,7 +190,7 @@ export function usePlexAuth() {
return {
loading,
setPlexAuthCookie,
getPlexAuthCookie,
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

@@ -1,12 +1,12 @@
import { API_HOSTNAME } from "../api";
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
export interface WatchStats {
interface WatchStats {
totalHours: number;
totalPlays: number;
moviePlays: number;
episodePlays: number;
musicPlays: number;
lastWatched: WatchContent[];
}
interface DayStats {
@@ -29,13 +29,6 @@ interface HomeStatItem {
media_type?: string;
}
export interface WatchContent {
title: string;
plays: number;
duration: number;
type: string;
}
interface PlaysGraphData {
categories: string[];
series: {
@@ -44,54 +37,56 @@ interface PlaysGraphData {
}[];
}
export async function tautulliRequest(
resource: string,
export function useTautulliStats() {
// Helper function to make Tautulli API calls
async function tautulliRequest(
cmd: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams(params);
const url = new URL(
`/api/v1/user/stats/${resource}?${queryParams}`,
API_HOSTNAME
);
const options: RequestInit = {
headers: {
"Content-Type": "application/json"
},
credentials: "include"
};
const queryParams = new URLSearchParams({
apikey: TAUTULLI_API_KEY,
cmd,
...params
});
const resp = await fetch(url, options);
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
const response = await fetch(url);
if (!resp.ok) {
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
if (!response.ok) {
throw new Error(`Tautulli API request failed: ${response.statusText}`);
}
const response = await resp.json();
if (response?.success !== true) {
throw new Error(response?.message || "Unknown API error");
const data = await response.json();
if (data.response?.result !== "success") {
throw new Error(data.response?.message || "Unknown API error");
}
return response.data;
return data.response.data;
} catch (error) {
console.error(`[Tautulli] Error with ${resource}:`, error);
console.error(`[Tautulli] Error with ${cmd}:`, error);
throw error;
}
}
// Fetch home statistics (pre-aggregated by Tautulli!)
export async function fetchHomeStats(
async function fetchHomeStats(
userId?: number,
timeRange = 30,
statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> {
try {
const params: Record<string, any> = {
days: timeRange,
type: statsType,
time_range: timeRange,
stats_type: statsType,
grouping: 0
};
const stats = await tautulliRequest("home_stats", params);
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("get_home_stats", params);
// Extract stats from the response
let totalPlays = 0;
@@ -130,7 +125,8 @@ export async function fetchHomeStats(
// Calculate total hours from duration
if (statsType === "duration") {
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
const totalDuration = [topMovies, topTV, topMusic].reduce(
(sum, stat) => {
if (!stat?.rows) return sum;
return (
sum +
@@ -139,29 +135,18 @@ export async function fetchHomeStats(
0
)
);
}, 0);
},
0
);
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
}
// Get "last_watched" stat which contains recent items
const limit = 12;
const lastWatched = stats
.find((s: any) => s.stat_id === "last_watched")
.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"
}));
return {
totalHours,
totalPlays,
moviePlays,
episodePlays,
musicPlays,
lastWatched
musicPlays
};
} catch (error) {
console.error("[Tautulli] Error fetching home stats:", error);
@@ -170,25 +155,32 @@ export async function fetchHomeStats(
totalPlays: 0,
moviePlays: 0,
episodePlays: 0,
musicPlays: 0,
lastWatched: []
musicPlays: 0
};
}
}
// Fetch plays by date (already aggregated by Tautulli!)
export async function fetchPlaysByDate(
async function fetchPlaysByDate(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<DayStats[]> {
try {
const params: Record<string, any> = {
days: timeRange,
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
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) => {
@@ -209,9 +201,10 @@ export async function fetchPlaysByDate(
}
// Fetch plays by day of week (already aggregated!)
export async function fetchPlaysByDayOfWeek(
async function fetchPlaysByDayOfWeek(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{
labels: string[];
movies: number[];
@@ -220,19 +213,24 @@ export async function fetchPlaysByDayOfWeek(
}> {
try {
const params: Record<string, any> = {
days: timeRange,
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"plays_by_dayofweek",
"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);
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 =
@@ -264,19 +262,24 @@ export async function fetchPlaysByDayOfWeek(
}
// Fetch plays by hour of day (already aggregated!)
export async function fetchPlaysByHourOfDay(
async function fetchPlaysByHourOfDay(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{ labels: string[]; data: number[] }> {
try {
const params: Record<string, any> = {
days: timeRange,
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"plays_by_hourofday",
"get_plays_by_hourofday",
params
);
@@ -297,3 +300,47 @@ export async function fetchPlaysByHourOfDay(
};
}
}
// 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,56 +0,0 @@
import { ref, computed } from "vue";
type Theme = "light" | "dark" | "auto";
const currentTheme = ref<Theme>("auto");
function systemDarkModeEnabled(): boolean {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
function applyTheme(theme: Theme) {
if (theme === "auto") {
const systemDark = systemDarkModeEnabled();
document.body.className = systemDark ? "dark" : "light";
} else {
document.body.className = theme;
}
}
export function useTheme() {
const savedTheme = computed(
() => (localStorage.getItem("theme-preference") as Theme) || "auto"
);
function initTheme() {
const theme = savedTheme.value;
currentTheme.value = theme;
applyTheme(theme);
// Listen for system theme changes when in auto mode
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", e => {
const currentSetting = localStorage.getItem("theme-preference") as Theme;
if (currentSetting === "auto") {
document.body.className = e.matches ? "dark" : "light";
}
});
}
function setTheme(theme: Theme) {
currentTheme.value = theme;
localStorage.setItem("theme-preference", theme);
applyTheme(theme);
}
return {
currentTheme,
savedTheme,
initTheme,
setTheme
};
}

View File

@@ -1,23 +0,0 @@
<template>
<svg
id="icon-cookie"
viewBox="0 0 32 32"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="transition-duration: 0s"
@click="$emit('click')"
@keydown="event => $emit('keydown', event)"
>
<circle cx="10" cy="21" r="2" fill="inherit" />
<circle cx="23" cy="20" r="2" fill="inherit" />
<circle cx="13" cy="10" r="2" fill="inherit" />
<circle cx="14" cy="15" r="1" fill="inherit" />
<circle cx="23" cy="5" r="2" fill="inherit" />
<circle cx="29" cy="3" r="1" fill="inherit" />
<circle cx="16" cy="23" r="1" fill="inherit" />
<path
fill="inherit"
d="M16 30C8.3 30 2 23.7 2 16S8.3 2 16 2c0.1 0 0.2 0 0.3 0l1.4 0.1-0.3 1.2c-0.1 0.4-0.2 0.9-0.2 1.3 0 2.8 2.2 5 5 5 1 0 2-0.3 2.9-0.9l1.3 1.5c-0.4 0.4-0.6 0.9-0.6 1.4 0 1.3 1.3 2.4 2.7 1.9l1.2-0.5 0.2 1.3C30 14.9 30 15.5 30 16c0 7.7-6.3 14-14 14zM15.3 4C9 4.4 4 9.6 4 16c0 6.6 5.4 12 12 12s12-5.4 12-12c0-0.1 0-0.3 0-0.4-2.3 0.1-4.2-1.7-4.2-4 0-0.1 0-0.1 0-0.2-0.5 0.1-1 0.2-1.6 0.2-3.9 0-7-3.1-7-7 0-0.2 0-0.4 0.1-0.6z"
/>
</svg>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<svg
id="icon-database"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="transition-duration: 0s"
fill="none"
stroke="currentColor"
stroke-width="2"
@click="$emit('click')"
@keydown="event => $emit('keydown', event)"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
/>
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 2c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM16 28c-6.627 0-12-5.373-12-12s5.373-12 12-12c6.627 0 12 5.373 12 12s-5.373 12-12 12z"
/>
<path
d="M13.333 10.667c-0.368 0-0.667 0.299-0.667 0.667v9.333c0 0.245 0.135 0.469 0.349 0.585 0.215 0.117 0.477 0.104 0.683-0.032l6.667-4.667c0.188-0.131 0.301-0.349 0.301-0.583s-0.113-0.452-0.301-0.583l-6.667-4.667c-0.109-0.076-0.239-0.115-0.365-0.115zM14.667 13.115l4.448 3.115-4.448 3.115v-6.229z"
/>
</svg>
</template>

View File

@@ -1,17 +0,0 @@
<template>
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M23 10V6c0-0.4719-0.1656-0.9094-0.4406-1.2531v0l-3.6688-4.5594C18.7969 0.0687 18.6531 0 18.5 0h-13C5.35 0 5.2062 0.0687 5.1094 0.1875L1.4406 4.75C1.1656 5.0906 1 5.5281 1 6v4c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 11.8656 1 12.4031 1 13v2c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 16.8656 1 17.4031 1 18v4c0 1.1031 0.8969 2 2 2h18c1.1031 0 2-0.8969 2-2v-4c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 16.1344 23 15.5969 23 15v-2c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 11.1344 23 10.5969 23 10zM5.7406 1h12.5219l2.4125 3H3.325zM21 22H3l-31e-4-4c0 0 0 0 31e-4 0v-1h18zM21.0031 15c0 0-31e-4 0 0 0L21 16H3v-1l-31e-4-2c0 0 0 0 31e-4 0v-1h18v1zM3 11V6h18v5z"
/>
<rect width="3" height="1.000008" x="16.999992" y="7.999992" />
<rect width="1.000008" height="1.000008" x="15" y="7.999992" />
<rect width="3" height="1.000008" x="16.999992" y="13.000008" />
<rect width="1.000008" height="1.000008" x="15" y="13.000008" />
<rect width="1.000008" height="1.000008" x="4.000008" y="18" />
<rect width="1.000008" height="1.000008" x="6" y="18" />
<rect width="1.000008" height="1.000008" x="7.999992" y="18" />
<rect width="1.000008" height="1.000008" x="10.000008" y="18" />
<rect width="3" height="1.000008" x="16.999992" y="19.999992" />
<rect width="1.000008" height="1.000008" x="15" y="19.999992" />
</svg>
</template>

View File

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

View File

@@ -1,17 +0,0 @@
<template>
<svg
id="icon-timer"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="transition-duration: 0s"
fill="none"
stroke="currentColor"
stroke-width="2"
@click="$emit('click')"
@keydown="event => $emit('keydown', event)"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</template>

View File

@@ -2,14 +2,42 @@ import { createApp } from "vue";
import router from "./routes";
import store from "./store";
import Toast from "./plugins/Toast";
import { useTheme } from "./composables/useTheme";
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
const { initTheme } = useTheme();
initTheme();
store.dispatch("darkmodeModule/findAndSetDarkmodeSupported");
store.dispatch("user/initUserFromCookie");
const app = createApp(App);

View File

@@ -16,13 +16,12 @@ export interface CookieOptions {
/**
* Read a cookie value.
*/
export function getAuthorizationCookie(): string | null {
const key = "authorization";
export function getCookie(name: string): string | null {
const array = document.cookie.split(";");
let match = null;
array.forEach((item: string) => {
const query = `${key}=`;
const query = `${name}=`;
if (!item.trim().startsWith(query)) return;
match = item.trim().substring(query.length);
});
@@ -133,7 +132,7 @@ const userModule: Module<UserState, RootState> = {
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
const jwtToken = getAuthorizationCookie();
const jwtToken = getCookie("authorization");
if (!jwtToken) return null;
const token = parseJwt(jwtToken);

View File

@@ -257,7 +257,6 @@
text-align: center;
z-index: 10;
padding: 2rem;
margin-top: calc(-1 * var(--header-size));
@include mobile {
padding: 1rem;

View File

@@ -1,9 +1,26 @@
<template>
<div class="activity">
<div v-if="plexUserId && plexUsername" class="activity">
<h1 class="activity__title">Watch Activity</h1>
<!-- Stats Overview -->
<stats-overview :watch-stats="watchStats" />
<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="controls">
<div class="control-group">
@@ -33,7 +50,7 @@
<div class="activity__charts">
<div class="chart-card">
<h3>Daily Activity</h3>
<h3 class="chart-card__title">Daily Activity</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayData"
@@ -48,7 +65,7 @@
</div>
<div class="chart-card">
<h3>Activity by Media Type</h3>
<h3 class="chart-card__title">Activity by Media Type</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayofweekData"
@@ -63,7 +80,7 @@
</div>
<div class="chart-card">
<h3>Viewing Patterns by Hour</h3>
<h3 class="chart-card__title">Viewing Patterns by Hour</h3>
<div class="chart-card__graph">
<Graph
v-if="hourlyData"
@@ -79,33 +96,80 @@
</div>
<!-- Top Content -->
<watch-history :top-content="topContent" />
<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 with Plex</h1>
<p>Go to Settings to link your Plex account</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue";
import StatsOverview from "@/components/activity/StatsOverview.vue";
import WatchHistory from "@/components/activity/WatchHistory.vue";
import {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay
} from "../composables/useTautulliStats";
import IconStop from "@/icons/IconStop.vue";
import type { Ref } from "vue";
import { useTautulliStats } from "@/composables/useTautulliStats";
import {
GraphTypes,
GraphValueTypes,
IGraphData
} from "../interfaces/IGraph";
import type { Ref } from "vue";
import type { WatchStats } from "../composables/useTautulliStats";
const store = useStore();
const days: Ref<number> = ref(30);
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
// 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 = [
{
type: GraphTypes.Plays,
@@ -129,6 +193,14 @@
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
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;
@@ -138,8 +210,30 @@
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
}
function activityPerDay(dataPromise: Promise<any>) {
dataPromise.then(dayData => {
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)
@@ -154,11 +248,8 @@
}
]
};
});
}
function playsByDayOfWeek(dataPromise: Promise<any>) {
dataPromise.then(weekData => {
// Activity by day of week (stacked by media type)
playsByDayofweekData.value = {
labels: weekData.labels,
series: [
@@ -167,42 +258,22 @@
{ name: "Music", data: weekData.music }
]
};
});
}
function hourly(hourlyPromise: Promise<any>) {
hourlyPromise.then(hourData => {
// Hourly distribution
hourlyData.value = {
labels: hourData.labels,
series: [{ name: "Plays", data: hourData.data }]
};
});
}
async function fetchChartData() {
try {
const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
fetchHomeStats(days.value, "duration").then(
(homeStats: WatchStats) => (watchStats.value = homeStats)
);
// Activity per day (line graph of last n days)
activityPerDay(fetchPlaysByDate(days.value, yAxis));
// Activity by day of week (stacked by media type)
playsByDayOfWeek(fetchPlaysByDayOfWeek(days.value, yAxis));
// Hourly distribution
hourly(fetchPlaysByHourOfDay(days.value, yAxis));
} catch (error) {
console.error("[ActivityPage] Error fetching chart data:", error);
}
}
onMounted(fetchChartData);
onMounted(() => {
if (plexUsername.value) {
fetchChartData();
}
});
</script>
<style lang="scss" scoped>
@@ -226,7 +297,7 @@
@include mobile-only {
font-size: 1.5rem;
margin: 1rem 0;
margin: 0 0 1rem 0;
}
}
@@ -239,7 +310,36 @@
gap: 1rem;
}
}
&__top-content {
margin-top: 2rem;
}
}
// .filter {
// display: flex;
// flex-direction: row;
// flex-wrap: wrap;
// align-items: center;
// margin-bottom: 2rem;
// h2 {
// margin-bottom: 0.5rem;
// width: 100%;
// font-weight: 400;
// }
// &-item:not(:first-of-type) {
// margin-left: 1rem;
// }
// .dayinput {
// font-size: 1.2rem;
// max-width: 3rem;
// background-color: $background-ui;
// color: $text-color;
// }
// }
.chart-card {
background: var(--background-ui);
@@ -251,7 +351,7 @@
padding: 1rem;
}
h3 {
&__title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
@@ -274,6 +374,13 @@
}
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.controls {
display: flex;
gap: 2rem;
@@ -343,4 +450,144 @@
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);
}
@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;
height: 3rem;
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;
h1 {
font-size: 1.65rem;
svg {
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
}
}
}
</style>

View File

@@ -1,53 +0,0 @@
<template>
<div class="not-authenticated">
<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 IconStop from "@/icons/IconStop.vue";
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.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;
height: 3rem;
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;
h1 {
font-size: 1.65rem;
svg {
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
}
}
}
</style>

View File

@@ -1,107 +1,40 @@
<template>
<div class="register auth-page">
<div class="auth-content auth-content--wide">
<div class="auth-header">
<h1 class="auth-title">Register new user</h1>
<p class="auth-subtitle">Create an account to get started</p>
</div>
<section>
<h1>Register new user</h1>
<form ref="formElement" class="auth-form" @submit.prevent>
<form ref="formElement" class="form">
<seasoned-input
v-model="username"
placeholder="Email address"
placeholder="username"
icon="Email"
type="email"
@keydown.enter="focusOnNextElement"
/>
<div class="register__password-section">
<div class="password-generator">
<button
type="button"
class="generator-toggle"
@click="toggleGenerator"
>
<IconKey class="toggle-icon" />
<span>{{
showGenerator
? "Hide Password Generator"
: "Generate Strong Password"
}}</span>
</button>
<div v-if="showGenerator" class="generator-content">
<password-generator
@password-generated="handlePasswordGenerated"
/>
</div>
</div>
<seasoned-input
v-model="password"
placeholder="Password"
placeholder="password"
icon="Keyhole"
type="password"
class="password-input"
@keydown.enter="focusOnNextElement"
/>
<seasoned-input
v-model="passwordRepeat"
placeholder="Confirm password"
placeholder="repeat password"
icon="Keyhole"
type="password"
class="password-input"
@keydown.enter="submit"
/>
</div>
<div v-if="password.length > 0" class="register__password-requirements">
<p class="requirements-title">Password must contain:</p>
<div class="requirements-grid">
<div class="requirement" :class="{ met: password.length >= 8 }">
<span class="requirement-icon">{{
password.length >= 8 ? "✓" : "✗"
}}</span>
<span class="requirement-text">At least 8 characters</span>
</div>
<div class="requirement" :class="{ met: /[A-Z]/.test(password) }">
<span class="requirement-icon">{{
/[A-Z]/.test(password) ? "✓" : "✗"
}}</span>
<span class="requirement-text">One uppercase letter</span>
</div>
<div class="requirement" :class="{ met: /[a-z]/.test(password) }">
<span class="requirement-icon">{{
/[a-z]/.test(password) ? "✓" : "✗"
}}</span>
<span class="requirement-text">One lowercase letter</span>
</div>
<div class="requirement" :class="{ met: /[0-9]/.test(password) }">
<span class="requirement-icon">{{
/[0-9]/.test(password) ? "✓" : "✗"
}}</span>
<span class="requirement-text">One number</span>
</div>
</div>
</div>
<seasoned-button class="auth-button" @click="submit">
Create Account
</seasoned-button>
<seasoned-button @click="submit">Register</seasoned-button>
</form>
<div class="auth-footer">
<p class="auth-footer-text">
Already have an account?
<router-link class="auth-link" to="/login">
Sign in here
</router-link>
</p>
</div>
<router-link class="link" to="/signin"
>Have a user? Sign in here</router-link
>
<seasoned-messages v-model:messages="messages"></seasoned-messages>
</div>
</div>
</section>
</template>
<script setup lang="ts">
@@ -111,8 +44,6 @@
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedInput from "@/components/ui/SeasonedInput.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 { register } from "../api";
import { focusFirstFormInput, focusOnNextElement } from "../utils";
@@ -124,7 +55,6 @@
const passwordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const formElement: Ref<HTMLFormElement> = ref(null);
const showGenerator = ref(false);
const store = useStore();
const router = useRouter();
@@ -140,198 +70,99 @@
message,
title,
type: ErrorMessageTypes.Error
});
} as IErrorMessage);
}
function addSuccessMessage(message: string, title?: string) {
function addWarningMessage(message: string, title?: string) {
messages.value.push({
message,
title,
type: ErrorMessageTypes.Success
type: ErrorMessageTypes.Warning
} as IErrorMessage);
}
function validate(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!username.value || username?.value?.length === 0) {
addWarningMessage("Missing username", "Validation error");
reject();
}
if (!password.value || password?.value?.length === 0) {
addWarningMessage("Missing password", "Validation error");
reject();
}
if (passwordRepeat.value == null || passwordRepeat.value.length === 0) {
addWarningMessage("Missing repeat password", "Validation error");
reject();
}
if (passwordRepeat.value !== password.value) {
addWarningMessage("Passwords do not match", "Validation error");
reject();
}
resolve(true);
});
}
function validate() {
const errors = [];
if (username.value.length === 0) {
errors.push("Email must not be empty");
function registerUser() {
register(username.value, password.value)
.then(data => {
if (data?.success && store.dispatch("user/login")) {
router.push({ name: "profile" });
}
if (password.value.length === 0) {
errors.push("Password must not be empty");
}
if (password.value.length < 8) {
errors.push("Password must be at least 8 characters");
}
if (!/[A-Z]/.test(password.value)) {
errors.push("Password must contain at least one uppercase letter");
}
if (!/[a-z]/.test(password.value)) {
errors.push("Password must contain at least one lowercase letter");
}
if (!/[0-9]/.test(password.value)) {
errors.push("Password must contain at least one number");
}
if (password.value !== passwordRepeat.value) {
errors.push("Passwords do not match");
}
if (errors.length > 0) {
errors.forEach(error => addErrorMessage(error, "Validation error"));
return Promise.reject();
}
return Promise.resolve(true);
}
function createUser() {
return register(username.value, password.value)
.then(response => {
addSuccessMessage(
"Account created successfully! Redirecting to login...",
"Success"
);
setTimeout(() => {
router.push("/login");
}, 2000);
return response;
})
.catch(error => {
addErrorMessage(error?.message || "Registration failed", "Error");
if (error?.status === 401) {
addErrorMessage("Incorrect username or password", "Access denied");
return null;
}
addErrorMessage(error?.message, "Unexpected error");
return null;
});
}
function submit() {
clearMessages();
validate().then(createUser);
}
function handlePasswordGenerated(generatedPassword: string) {
password.value = generatedPassword;
passwordRepeat.value = generatedPassword;
}
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
validate().then(registerUser);
}
</script>
<style lang="scss" scoped>
@import "scss/shared-auth";
@import "scss/variables";
.register {
// Password inputs use monospace font
:deep(.password-input input[type="password"]),
:deep(.password-input input[type="text"]) {
font-family: "Courier New", monospace;
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
.form > div,
input,
button {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0px;
}
}
.register__password-section {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.password-generator {
.generator-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.875rem 1rem;
background: var(--background-ui);
border: 1px solid var(--text-color-10);
border-radius: 8px;
color: var(--text-color);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--background-color-secondary);
border-color: var(--text-color-20);
}
.toggle-icon {
width: 18px;
height: 18px;
color: var(--highlight-color);
}
}
.generator-content {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--text-color-10);
}
}
.register__password-requirements {
background: var(--background-ui);
border: 1px solid var(--text-color-10);
border-radius: 8px;
padding: 1.25rem;
margin-top: -0.25rem;
.requirements-title {
margin: 0 0 1rem 0;
font-size: 0.95rem;
font-weight: 500;
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.requirements-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.requirement {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-color-60);
&-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--text-color-10);
font-size: 0.75rem;
font-weight: bold;
color: var(--text-color-60);
}
&-text {
line-height: 1.3;
}
&.met {
color: var(--success-color, #51cf66);
.requirement-icon {
background: var(--success-color, #51cf66);
color: white;
}
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -2,30 +2,58 @@
<section class="settings">
<div class="settings__container">
<!-- Profile Hero Card -->
<ProfileHero />
<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>
<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>
<!-- Settings Grid -->
<div class="settings__grid">
<!-- Left Column -->
<div class="settings__column">
<!-- Left Column: Quick Settings -->
<div class="settings__column settings__column--left">
<section class="settings-section settings-section--compact">
<div class="settings-section-header"><h2>Appearance</h2></div>
<h2 class="section-header">Appearance</h2>
<theme-preferences />
</section>
<section class="settings-section settings-section--compact">
<security-settings />
<h2 class="section-header">Security</h2>
<change-password />
</section>
</div>
<!-- Right Column -->
<div class="settings__column">
<!-- Right Column: Data-Heavy Sections -->
<div class="settings__column settings__column--right">
<section class="settings-section">
<div class="settings-section-header"><h2>Integrations</h2></div>
<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>
@@ -35,13 +63,12 @@
</template>
<script setup lang="ts">
import { inject, onMounted } from "vue";
import { inject, computed, onMounted } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import ProfileHero from "@/components/settings/ProfileHero.vue";
import ThemePreferences from "@/components/settings/ThemePreferences.vue";
import PlexSettings from "@/components/settings/PlexSettings.vue";
import SecuritySettings from "@/components/settings/SecuritySettings.vue";
import ChangePassword from "@/components/profile/ChangePassword.vue";
import DataExport from "@/components/settings/DataExport.vue";
import { getSettings } from "../api";
@@ -51,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({
@@ -77,14 +127,13 @@
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/shared-settings";
.settings {
min-height: calc(100vh - var(--header-size));
padding: 2rem 1.5rem;
@include mobile-only {
padding: 0.5rem;
padding: 1rem;
}
&__container {
@@ -118,6 +167,184 @@
@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: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
&__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;
}
}
}
.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;
}
}
@@ -128,16 +355,36 @@
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.5rem;
padding: 1rem;
}
&--compact {
// Tighter padding for quick settings, but same header size
padding: 1rem;
// Tighter spacing for quick settings
.section-header {
font-size: 1.25rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
@include mobile-only {
padding: 0.5rem;
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

@@ -1,44 +1,31 @@
<template>
<div class="signin auth-page">
<div class="auth-content">
<div class="auth-header">
<h1 class="auth-title">Sign in</h1>
<p class="auth-subtitle">Welcome back! Please enter your credentials</p>
</div>
<section>
<h1>Sign in</h1>
<form ref="formElement" class="auth-form">
<form ref="formElement" class="form">
<seasoned-input
v-model="username"
placeholder="Email address"
placeholder="username"
icon="Email"
type="email"
@keydown.enter="focusOnNextElement"
/>
<seasoned-input
v-model="password"
placeholder="Password"
placeholder="password"
icon="Keyhole"
type="password"
@keydown.enter="submit"
/>
<seasoned-button class="auth-button" @click="submit">
Sign In
</seasoned-button>
<seasoned-button @click="submit">sign in</seasoned-button>
</form>
<div class="auth-footer">
<p class="auth-footer-text">
Don't have an account?
<router-link class="auth-link" to="/register">
Register here
</router-link>
</p>
</div>
<router-link class="link" to="/register"
>Don't have a user? Register here</router-link
>
<seasoned-messages v-model:messages="messages" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
@@ -73,38 +60,43 @@
message,
title,
type: ErrorMessageTypes.Error
} as IErrorMessage);
}
function addWarningMessage(message: string, title?: string) {
messages.value.push({
message,
title,
type: ErrorMessageTypes.Warning
} as IErrorMessage);
}
function validate(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!username.value || username?.value?.length === 0) {
addWarningMessage("Missing username", "Validation error");
reject();
}
if (!password.value || password?.value?.length === 0) {
addWarningMessage("Missing password", "Validation error");
reject();
}
resolve(true);
});
}
function validate() {
const errors = [];
if (username.value.length === 0) {
errors.push("Username must not be empty");
}
if (password.value.length === 0) {
errors.push("Password must not be empty");
}
if (errors.length > 0) {
errors.forEach(error => addErrorMessage(error, "Validation error"));
return Promise.reject();
}
return Promise.resolve(true);
}
function signin() {
return login(username.value, password.value)
.then(response => {
store.dispatch("user/login", response.user);
router.push("/");
return response;
login(username.value, password.value, true)
.then(data => {
if (data?.success && store.dispatch("user/login")) {
router.push({ name: "profile" });
}
})
.catch(error => {
if (error.error === "Incorrect username or password.") {
addErrorMessage(error.error, "Authentication failed");
if (error?.status === 401) {
addErrorMessage("Incorrect username or password", "Access denied");
return null;
}
@@ -120,13 +112,28 @@
</script>
<style lang="scss" scoped>
@import "scss/shared-auth";
@import "scss/variables";
.signin {
// Password input uses monospace font
:deep(input[type="password"]),
:deep(input[type="text"][placeholder="Password"]) {
font-family: "Courier New", monospace;
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -59,7 +59,7 @@
@include mobile-only {
font-size: 1.5rem;
margin: 1rem 0;
margin: 0 0 1rem 0;
}
}

View File

@@ -3,8 +3,6 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
/* eslint-disable-next-line import-x/no-cycle */
import store from "./store";
import { usePlexAuth } from "./composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
declare global {
interface Window {
@@ -85,11 +83,6 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
{
name: "missing-plex-auth",
path: "/missing/plex",
component: () => import("./pages/MissingPlexAuthPage.vue")
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
@@ -118,8 +111,16 @@ const hasPlexAccount = () => {
if (store.getters["user/plexUserId"] !== null) return true;
// Fallback to localStorage
const authToken = getPlexAuthCookie();
return !!authToken;
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"];
@@ -135,14 +136,15 @@ router.beforeEach(
// send user to signin page.
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!loggedIn()) {
next({ path: "/login" });
next({ path: "/signin" });
}
}
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
if (!hasPlexAccount()) {
next({
path: "/missing/plex"
path: "/settings",
query: { missingPlexAccount: true }
});
}
}

View File

@@ -1,100 +0,0 @@
// Shared styles for authentication pages (signin, register)
@import "variables";
@import "media-queries";
// Base auth page layout
.auth-page {
padding: 3rem;
max-width: 100%;
@include mobile-only {
padding: 0.75rem;
}
}
.auth-content {
max-width: 600px;
@include mobile-only {
max-width: 100%;
}
&--wide {
max-width: 700px;
}
}
.auth-header {
margin-bottom: 2.5rem;
@include mobile-only {
margin-bottom: 2rem;
}
}
.auth-title {
margin: 0 0 0.75rem 0;
font-size: 2.5rem;
font-weight: 600;
color: $text-color;
line-height: 1.2;
@include mobile-only {
font-size: 2rem;
}
}
.auth-subtitle {
margin: 0;
font-size: 1.1rem;
font-weight: 300;
color: var(--text-color-60);
line-height: 1.5;
@include mobile-only {
font-size: 1rem;
}
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-bottom: 2rem;
}
.auth-button {
margin-top: 0.5rem;
max-width: 200px;
@include mobile-only {
max-width: 100%;
}
}
.auth-footer {
padding-top: 2rem;
border-top: 1px solid var(--text-color-10);
}
.auth-footer-text {
margin: 0;
font-size: 1rem;
color: var(--text-color-60);
@include mobile-only {
font-size: 0.95rem;
}
}
.auth-link {
color: var(--highlight-color);
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
}

View File

@@ -1,30 +0,0 @@
@import "./media-queries.scss";
.settings-section-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;
}
}
.settings-section-header {
margin-bottom: 1rem;
h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
}
p {
margin: 0;
color: var(--text-color-70);
font-size: 0.95rem;
line-height: 1.6;
}
}

View File

@@ -89,8 +89,7 @@ export function setUrlQueryParameter(parameter: string, value: string): void {
const params = new URLSearchParams();
params.append(parameter, value);
const url = `${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ""
const url = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""
}${window.location.pathname}${params.toString().length ? `?${params}` : ""}`;
window.history.pushState({}, "search", url);
@@ -130,17 +129,3 @@ export function convertSecondsToHumanReadable(_value, values = null) {
return value;
}
export function formatNumber(n: number) {
if (!n?.toString()) return n;
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
export 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 / k ** i) * 100) / 100} ${sizes[i]}`;
}

View File

@@ -1,7 +1,7 @@
export function getLibraryIcon(type: string): string {
const icons: Record<string, string> = {
movies: "🎬",
"tv shows": "📺",
shows: "📺",
music: "🎵"
};
return icons[type] || "📁";
@@ -10,7 +10,7 @@ export function getLibraryIcon(type: string): string {
export function getLibraryIconComponent(type: string): string {
const components: Record<string, string> = {
movies: "IconMovie",
"tv shows": "IconShow",
shows: "IconShow",
music: "IconMusic"
};
return components[type] || "IconMovie";
@@ -19,7 +19,7 @@ export function getLibraryIconComponent(type: string): string {
export function getLibraryTitle(type: string): string {
const titles: Record<string, string> = {
movies: "Movies",
"tv shows": "TV Shows",
shows: "TV Shows",
music: "Music"
};
return titles[type] || type;
@@ -62,8 +62,8 @@ export function processLibraryItem(
// Get poster/thumbnail URL
let posterUrl = null;
// For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
if (libraryType === "tv shows") {
// For TV shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
if (libraryType === "shows") {
if (item.grandparentThumb) {
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
} else if (item.thumb) {
@@ -92,14 +92,14 @@ export function processLibraryItem(
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
}
// For tv shows, use grandparent data (show info) instead of episode info
// For shows, use grandparent data (show info) instead of episode info
const title =
libraryType === "tv shows" && item.grandparentTitle
libraryType === "shows" && item.grandparentTitle
? item.grandparentTitle
: item.title;
const year =
libraryType === "tv shows" && item.grandparentYear
libraryType === "shows" && item.grandparentYear
? item.grandparentYear
: item.year || item.parentYear || new Date().getFullYear();
@@ -109,12 +109,11 @@ export function processLibraryItem(
poster: posterUrl,
fallbackIcon: getLibraryIcon(libraryType),
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
type: libraryType,
ratingKey,
plexUrl
};
if (libraryType === "tv shows") {
if (libraryType === "shows") {
return {
...baseItem,
episodes: item.leafCount || 0
@@ -158,7 +157,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
totalDuration += item.duration;
}
if (libraryType === "tv shows" && item.leafCount) {
if (libraryType === "shows" && item.leafCount) {
totalEpisodes += item.leafCount;
} else if (libraryType === "music" && item.leafCount) {
totalTracks += item.leafCount;