Compare commits

..

49 Commits

Author SHA1 Message Date
e69f0c52b8 Minor improvements and code cleanup across components
- Refactor PlexSettings component for better organization
- Add utility functions to utils.ts for shared logic
- Update API methods for improved error handling
- Clean up navigation icon components
- Remove unused code from CommandPalette and SeasonedButton
- Fix minor issues in Movie popup component
- Update page component imports (RegisterPage, TorrentsPage)
2026-03-08 20:57:37 +01:00
1b99399b4c Refactor ActivityPage with extracted stats and history components
- Extract StatsOverview component for watch statistics display
- Extract WatchHistory component for recently watched content
- Reduce ActivityPage from 490 to 346 lines (~29% reduction)
- Move stats card styles to StatsOverview component
- Move watch history list styles to WatchHistory component
- Clean up useTautulliStats composable
- Improve Graph component for better chart rendering
- Maintain three clear sections: stats, controls+graphs, history
- Follow component extraction pattern from settings refactor
2026-03-08 20:57:20 +01:00
990dde4d31 Add Plex integration icons and improve authentication flow
- Create IconPlex with play button symbol for Plex login button
- Create IconServer for server information display
- Create IconSync for library sync operations
- Replace inline SVGs with icon components in PlexAuthButton
- Replace inline SVGs with icon components in PlexServerInfo
- Add MissingPlexAuthPage for better auth error handling
- Update routes to redirect missing Plex auth to dedicated page
- Refactor usePlexApi and usePlexAuth for better composable patterns
- Remove deprecated usePlexLibraries composable
- Improve PlexLibraryModal and PlexLibraryStats components
- Clean up Plex-related helper utilities
2026-03-08 20:57:08 +01:00
493ac02bab Create centralized theme management with useTheme composable
- Extract theme initialization logic from main.ts into useTheme composable
- Add setTheme() and initTheme() functions for consistent theme handling
- Update ThemePreferences to use useTheme instead of duplicate logic
- Remove unused DarkmodeToggle component
- Clean up main.ts from ~49 to 28 lines (theme logic now in composable)
- Establish single source of truth for theme management
- Follow Vue 3 composables pattern for better organization and testability
2026-03-08 20:56:54 +01:00
e8a0598e8f Refactor data management with browser and server storage sections
- Split LocalStorageManager into modular StorageManager component
- Create StorageSectionBrowser for localStorage/sessionStorage/cookies UI
- Create StorageSectionServer for server-side data management (mock)
- Extract ExportSection component from DataExport
- Add storage type icons (IconCookie, IconDatabase, IconTimer)
- Implement collapsible storage sections with visual indicators
- Add colored borders and gradients per storage type
- Display item counts and total size in section headers
- Improve delete button layout using CSS Grid
- Reduce DataExport from ~824 lines to focused component
2026-03-08 20:56:46 +01:00
9c6e6938e9 Refactor settings page with improved component structure
- Split SettingsPage into two-column layout with ProfileHero component
- Extract SecuritySettings component with user-friendly messaging
- Create RequestHistory component for Plex request tracking
- Optimize ThemePreferences component (reduced from ~368 to cleaner structure)
- Improve PasswordGenerator slider UX with better visual feedback
- Standardize typography across all settings sections (h2: 1.5rem, 700 weight)
- Add shared-settings.scss for consistent styling patterns
- Remove redundant ChangePassword description (now in SecuritySettings)
2026-03-08 20:56:34 +01:00
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
86 changed files with 3271 additions and 2627 deletions

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: svgs src/icons
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "svgs";
const OUTPUT_DIR = process.argv[3] || "src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const baseName = file.replace(/\.svg$/i, "");
// Convert kebab-case to PascalCase (e.g., clipboard-text -> ClipboardText)
const pascalCase = baseName
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
const destFileName = `Icon${pascalCase}.vue`;
const dest = path.join(outputDir, destFileName);
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: ./svgs ./svelte
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "../svgs";
const OUTPUT_DIR = process.argv[3] || "../src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const dest = path.join(outputDir, file.replace(/\.svg$/i, ".vue"));
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

View File

@@ -13,7 +13,7 @@
<!-- Popup that will show above existing rendered content -->
<popup />
<!-- Command Palette for quick navigation -->
<!-- Command Palette -->
<command-palette />
</div>
</template>
@@ -62,7 +62,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

@@ -135,17 +135,6 @@ const getTmdbMovieListByName = async (
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
const getTmdbMovieDiscoverByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/discover/${name}`, API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
// Fetches requested items.
const getRequests = async (page = 1) => {
const url = new URL("/api/v2/request", API_HOSTNAME);
@@ -576,7 +565,6 @@ export {
getShowCredits,
getPersonCredits,
getTmdbMovieListByName,
getTmdbMovieDiscoverByName,
searchTmdb,
getUserRequests,
getRequests,

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;
}
}
.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;
&:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
transform: scale(1.03);
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
.name {
font-weight: 500;
}
}
.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%
);
}
.character {
font-size: 0.9em;
}
.cast-card__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.meta {
font-size: 0.9em;
color: var(--text-color-70);
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
// margin-top: auto;
max-height: calc((0.9em * var(--line-height)) * 1);
}
.cast-card__content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 60px;
}
a {
display: block;
text-decoration: none;
height: 100%;
display: flex;
flex-direction: column;
}
.cast-card__name {
margin: 0;
font-size: 0.95rem;
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));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
img {
width: 100%;
height: auto;
max-height: 210px;
background-color: var(--background-color);
object-fit: cover;
}
}
</style>

View File

@@ -1,277 +0,0 @@
<template>
<section class="discover-minimal">
<div class="discover-minimal-header">
<div class="header-content">
<h2 class="discover-title">Explore Collections</h2>
<p class="discover-description">
Curated selections organized by genre, mood, and decade
</p>
</div>
<router-link to="/discover" class="view-all-link">
<span class="desktop-only">View All Categories </span>
<span class="mobile-only">View All </span>
</router-link>
</div>
<DiscoverShowcase @select="navigateToDiscover" />
<div class="featured-collections-wrapper">
<div class="featured-collections-header">
<div class="header-decorator"></div>
<h3 class="featured-title">Featured Picks</h3>
<div class="header-decorator"></div>
</div>
<div class="featured-collections">
<ResultsSection
v-for="list in featuredLists"
:key="list.id"
:api-function="list.apiFunction"
:title="list.title"
:short-list="true"
section-type="discover"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import DiscoverShowcase from "@/components/DiscoverShowcase.vue";
import { getTmdbMovieDiscoverByName } from "../api";
const router = useRouter();
const featuredLists = [
{
id: "feel_good",
title: "Feel Good",
apiFunction: () => getTmdbMovieDiscoverByName("feel_good")
},
{
id: "2000s_classics",
title: "2000s Classics",
apiFunction: () => getTmdbMovieDiscoverByName("2000s_classics")
},
{
id: "horror_hits",
title: "Horror Hits",
apiFunction: () => getTmdbMovieDiscoverByName("horror_hits")
}
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.discover-minimal {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0.03) 50%,
rgba(255, 255, 255, 0.01) 100%
);
padding: 3rem 0;
position: relative;
margin: 2rem 0;
width: 100%;
&::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.2) 50%,
transparent 100%
);
}
&::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
}
@include mobile {
padding: 1rem 0 0.5rem;
margin: 0;
background: transparent;
&::before,
&::after {
display: none;
}
}
}
.discover-minimal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem 2rem;
gap: 1rem;
@include mobile {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 1rem 0.6rem;
gap: 0.75rem;
}
.header-content {
flex: 1;
@include mobile {
min-width: 0;
}
}
.discover-title {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
color: var(--text-color);
letter-spacing: -0.5px;
@include mobile {
font-size: 1.75rem;
margin: 0 0 0.15rem;
font-weight: 600;
}
}
.discover-description {
margin: 0;
font-size: 0.95rem;
color: $text-color-70;
font-weight: 300;
@include mobile {
display: none;
}
}
.view-all-link {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 25px;
color: $text-color-70;
font-size: 0.9rem;
font-weight: 400;
text-decoration: none;
transition: all 0.3s ease;
white-space: nowrap;
@include mobile {
padding: 0.45rem 0.85rem;
font-size: 0.75rem;
border-radius: 20px;
flex-shrink: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-color);
transform: translateX(2px);
}
}
}
.featured-collections-wrapper {
padding-top: 2rem;
position: relative;
@include mobile {
margin-top: 0;
padding-top: 0.5rem;
}
}
.featured-collections-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0 1.5rem 1.5rem;
max-width: 1400px;
margin: 0 auto;
@include mobile {
padding: 0 1rem 0.4rem;
gap: 0.4rem;
}
.header-decorator {
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0.3) 100%
);
&:last-child {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
}
}
.featured-title {
margin: 0;
font-size: 1.4rem;
font-weight: 500;
color: var(--text-color);
letter-spacing: 0.5px;
white-space: nowrap;
text-transform: uppercase;
font-size: 0.9rem;
color: $text-color-70;
@include mobile {
font-size: 0.8rem;
}
}
}
.featured-collections {
background: rgba(0, 0, 0, 0.15);
border-radius: 20px;
max-width: calc(100% - 4rem);
margin: 0 auto;
@include mobile {
border-radius: 12px;
padding: 0.25rem 0;
max-width: calc(100% - 2rem);
}
}
</style>

View File

@@ -1,360 +0,0 @@
<template>
<div class="category-showcase">
<div class="categories-grid">
<button
v-for="category in categories"
:key="category.id"
class="category-card"
:class="[
`category-${category.id}`,
{ active: activeCategory === category.id }
]"
@click="$emit('select', category.id)"
>
<component :is="category.icon" class="category-icon" />
<div class="category-info">
<h3 class="category-name">{{ category.label }}</h3>
<p class="category-count">
<span class="desktop-only">{{ category.count }} collections</span>
<span class="mobile-only">{{ category.count }}</span>
</p>
</div>
<div class="category-arrow"></div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import IconPopular from "@/icons/IconPopular.vue";
import IconSpotlights from "@/icons/IconSpotlights.vue";
import IconTheater from "@/icons/IconTheater.vue";
import IconCalendar from "@/icons/IconCalendar.vue";
import IconStar from "@/icons/IconStar.vue";
interface Props {
activeCategory?: string;
}
withDefaults(defineProps<Props>(), {
activeCategory: ""
});
defineEmits<{
select: [categoryId: string];
}>();
const router = useRouter();
const categories = [
{ id: "popular", label: "Popular", icon: IconPopular, count: 5 },
{ id: "genres", label: "Genres", icon: IconSpotlights, count: 13 },
{ id: "moods", label: "Moods & Themes", icon: IconTheater, count: 7 },
{ id: "decades", label: "By Decade", icon: IconCalendar, count: 4 },
{ id: "special", label: "Special Collections", icon: IconStar, count: 11 }
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.category-showcase {
padding: 1.5rem;
padding-top: 0;
@include mobile {
padding: 0 1rem 0.6rem;
}
}
.categories-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
max-width: 1200px;
margin: 0 auto;
justify-content: center;
@include mobile {
gap: 0.45rem;
}
}
.category-card {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.9rem;
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
@include mobile {
padding: 0.45rem 0.7rem;
gap: 0.4rem;
border-radius: 20px;
}
&.category-popular {
background: rgba(255, 80, 80, 0.15);
border-color: rgba(255, 80, 80, 0.3);
.category-icon {
fill: rgba(255, 120, 120, 0.9);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.15);
border-color: rgba(80, 140, 255, 0.3);
.category-icon {
fill: rgba(120, 170, 255, 0.9);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.15);
border-color: rgba(160, 80, 255, 0.3);
.category-icon {
fill: rgba(180, 120, 255, 0.9);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.15);
border-color: rgba(80, 200, 200, 0.3);
.category-icon {
fill: rgba(100, 220, 220, 0.9);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.15);
border-color: rgba(255, 180, 80, 0.3);
.category-icon {
fill: rgba(255, 200, 120, 0.9);
}
}
&.active {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
&.category-popular {
background: rgba(255, 80, 80, 0.3);
border-color: rgba(255, 80, 80, 0.6);
.category-icon {
fill: rgba(255, 160, 160, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.3);
border-color: rgba(80, 140, 255, 0.6);
.category-icon {
fill: rgba(160, 210, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.3);
border-color: rgba(160, 80, 255, 0.6);
.category-icon {
fill: rgba(220, 160, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.3);
border-color: rgba(80, 200, 200, 0.6);
.category-icon {
fill: rgba(140, 255, 255, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.3);
border-color: rgba(255, 180, 80, 0.6);
.category-icon {
fill: rgba(255, 230, 160, 1);
}
}
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15) 0%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
&.category-popular {
background: rgba(255, 80, 80, 0.25);
border-color: rgba(255, 80, 80, 0.5);
.category-icon {
fill: rgba(255, 140, 140, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.25);
border-color: rgba(80, 140, 255, 0.5);
.category-icon {
fill: rgba(140, 190, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.25);
border-color: rgba(160, 80, 255, 0.5);
.category-icon {
fill: rgba(200, 140, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.25);
border-color: rgba(80, 200, 200, 0.5);
.category-icon {
fill: rgba(120, 240, 240, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.25);
border-color: rgba(255, 180, 80, 0.5);
.category-icon {
fill: rgba(255, 220, 140, 1);
}
}
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
}
.category-icon {
width: 24px;
height: 24px;
fill: var(--text-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
@include mobile {
width: 16px;
height: 16px;
}
}
.category-info {
display: flex;
align-items: center;
gap: 0.6rem;
line-height: 1;
@include mobile {
gap: 0.4rem;
}
}
.category-name {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: white;
white-space: nowrap;
@include mobile {
font-size: 0.8rem;
}
}
.category-count {
margin: 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.6rem;
border-radius: 12px;
white-space: nowrap;
@include mobile {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
}
}
.category-arrow {
font-size: 1.1rem;
color: white;
opacity: 0;
transition: all 0.3s ease;
margin-left: 0.25rem;
@include mobile {
display: none;
}
}
}
</style>

View File

@@ -31,22 +31,12 @@
info?: string | Array<string>;
link?: string;
shortList?: boolean;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
const props = defineProps<Props>();
const urlify = computed(() => {
const normalizedTitle = props.title
.toLowerCase()
.replace(/'s\b/g, "") // Remove possessive 's
.replace(/[^\w\d\s-]/g, "") // Remove special characters (keep word chars, dashes, digits, spaces)
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/-/g, "_") // Replace dash with underscore
.replace(/_+/g, "_"); // Replace multiple underscores with single underscore
return `/${props.sectionType}/${normalizedTitle}`;
return `/list/${props.title.toLowerCase().replace(" ", "_")}`;
});
const prettify = computed(() => {

View File

@@ -1,8 +1,6 @@
<template>
<div ref="resultSection" class="resultSection">
<page-header
v-bind="{ title, info, shortList, sectionType: props.sectionType }"
/>
<page-header v-bind="{ title, info, shortList }" />
<div
v-if="!loadedPages.includes(1) && loading == false"
@@ -42,12 +40,9 @@
title: string;
apiFunction: (page: number) => Promise<IList>;
shortList?: boolean;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
const props = defineProps<Props>();
const results: Ref<ListResults> = ref([]);
const page: Ref<number> = ref(1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,14 +15,13 @@
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import NavigationIcon from "@/components/header/NavigationIcon.vue";
import IconMailboxFull from "@/icons/IconMailboxFull.vue";
import IconInbox from "@/icons/IconInbox.vue";
import IconNowPlaying from "@/icons/IconNowPlaying.vue";
import IconPopular from "@/icons/IconPopular.vue";
import IconUpcoming from "@/icons/IconUpcoming.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconHelm from "@/icons/IconHelm.vue";
import IconDiscover from "@/icons/IconDiscover.vue";
import IconBinoculars from "@/icons/IconBinoculars.vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
const route = useRoute();
@@ -31,18 +30,13 @@
{
title: "Requests",
route: "/list/requests",
icon: IconMailboxFull
icon: IconInbox
},
{
title: "Now Playing",
route: "/list/now_playing",
icon: IconNowPlaying
},
{
title: "Discover",
route: "/discover",
icon: IconDiscover
},
{
title: "Popular",
route: "/list/popular",
@@ -64,7 +58,7 @@
title: "Torrents",
route: "/torrents",
requiresAuth: true,
icon: IconHelm
icon: IconBinoculars
},
{
title: "Settings",

View File

@@ -9,19 +9,14 @@
unclickable: !!!stat.clickable
}"
@click="
stat.clickable &&
stat.value?.total > 0 &&
!loading &&
handleClick(stat.key)
stat.clickable && stat.value?.total > 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">{{ formatNumber(stat.value?.total) }}</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
@@ -31,7 +26,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from "@/utils";
import { formatNumber } from '@/utils'
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";

View File

@@ -3,14 +3,14 @@
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<IconServer class="label-icon" style="fill: var(--text-color)" />
<IconServer class="label-icon" />
Plex server name
</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)" />
<IconSync class="label-icon" />
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
@@ -82,6 +82,7 @@
}
svg {
color: var(--text-color-60);
flex-shrink: 0;
}

View File

@@ -32,7 +32,9 @@
<IconThumbsUp v-if="media?.exists_in_plex" />
<IconThumbsDown v-else />
{{
!media?.exists_in_plex ? "Not yet available" : "Already available"
!media?.exists_in_plex
? "Not yet available"
: "Already available 🎉"
}}
</action-button>
@@ -44,11 +46,6 @@
{{ !requested ? `Request ${type}?` : "Already requested" }}
</action-button>
<action-button v-if="admin && requested" :active="false">
<IconTombstone />
Remove request
</action-button>
<action-button
v-if="plexUserId && media?.exists_in_plex"
@click="openInPlex"
@@ -69,15 +66,9 @@
<action-button
v-if="admin === true"
:active="showTorrents"
@click="
showTorrents = !showTorrents;
helmKey++;
"
@click="showTorrents = !showTorrents"
>
<IconHelm
:key="helmKey"
:class="showTorrents ? 'helm-spin-forward' : 'helm-spin-reverse'"
/>
<IconBinoculars />
Search for torrents
<span v-if="numberOfTorrentResults" class="meta">{{
numberOfTorrentResults
@@ -185,9 +176,8 @@
import IconInfo from "../../icons/IconInfo.vue";
import IconRequest from "../../icons/IconRequest.vue";
import IconRequested from "../../icons/IconRequested.vue";
import IconHelm from "../../icons/IconHelm.vue";
import IconBinoculars from "../../icons/IconBinoculars.vue";
import IconPlay from "../../icons/IconPlay.vue";
import IconTombstone from "../../icons/IconTombstone.vue";
import TorrentList from "../torrent/TruncatedTorrentResults.vue";
import CastList from "../CastList.vue";
import Detail from "./Detail.vue";
@@ -225,7 +215,8 @@
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 COLORS_URL = "http://localhost:8080/colors";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
@@ -236,7 +227,6 @@
const compact: Ref<boolean> = ref();
const loading: Ref<boolean> = ref();
const backdropElement: Ref<HTMLElement> = ref();
const helmKey: Ref<number> = ref(0);
const store = useStore();
@@ -363,7 +353,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");
@@ -601,30 +591,4 @@
.fade-leave-to {
opacity: 0;
}
.helm-spin-forward {
animation: helm-spin-forward 0.6s ease-in-out;
}
.helm-spin-reverse {
animation: helm-spin-reverse 0.6s ease-in-out;
}
@keyframes helm-spin-forward {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes helm-spin-reverse {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-270deg);
}
}
</style>

View File

@@ -24,8 +24,8 @@
<script setup lang="ts">
import { ref } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue";
import RequestHistory from "./RequestHistory.vue";
import ExportSection from "./ExportSection.vue"
import RequestHistory from "./RequestHistory.vue"
import DangerZoneAction from "./DangerZoneAction.vue";
const requestStats = ref({

View File

@@ -72,6 +72,8 @@
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>

View File

@@ -74,6 +74,7 @@
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,69 +1,68 @@
<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)"
<table>
<thead class="table__header noselect">
<tr>
<th
v-for="column in visibleColumns"
:key="column"
:class="column === selectedColumn ? 'active' : null"
@click="sortTable(column)"
>
{{ option.label }}
</button>
</div>
</div>
{{ column }}
<span v-if="prevCol === column && direction"></span>
<span v-if="prevCol === column && !direction"></span>
</th>
</tr>
</thead>
<table>
<thead class="table__header noselect">
<tr>
<th
class="name-header"
:class="selectedSort === 'name' ? 'active' : null"
@click="changeSort('name')"
>
Name
<span v-if="selectedSort === 'name'">{{
direction ? "" : ""
}}</span>
</th>
<th class="add-header">Add</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in sortedTorrents"
:key="torrent.magnet"
class="table__content"
<tbody>
<tr
v-for="torrent in torrents"
:key="torrent.magnet"
class="table__content"
>
<td
class="torrent-info"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
<td
class="torrent-info"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
<div class="torrent-title">{{ torrent.name }}</div>
<div 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
class="download"
@click="() => emit('magnet', torrent)"
@keydown.enter="() => emit('magnet', torrent)"
>
<IconMagnet />
</td>
</tr>
</tbody>
</table>
</div>
<div class="torrent-title">{{ torrent.name }}</div>
<div v-if="isMobile" class="torrent-meta">
<span class="meta-item">{{ torrent.size }}</span>
<span class="meta-separator"></span>
<span class="meta-item">{{ torrent.seed }} seeders</span>
</div>
</td>
<td
v-if="!isMobile"
class="torrent-seed"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.seed }}
</td>
<td
v-if="!isMobile"
class="torrent-size"
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.size }}
</td>
<td
class="download"
@click="() => emit('magnet', torrent)"
@keydown.enter="() => emit('magnet', torrent)"
>
<IconMagnet />
</td>
</tr>
</tbody>
</table>
</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)
);
}
return sorted;
});
function changeSort(sortBy: string) {
if (prevSort.value === sortBy) {
direction.value = !direction.value;
} else {
direction.value = false;
selectedSort.value = sortBy;
}
prevSort.value = sortBy;
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
window.addEventListener("resize", handleResize);
});
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: auto;
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);
}
background-color: var(--background-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;
}
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

@@ -147,7 +147,6 @@
import IconMagnet from "@/icons/IconMagnet.vue";
import IconProfileLock from "@/icons/IconProfileLock.vue";
import IconShow from "@/icons/IconShow.vue";
import IconBinoculars from "@/icons/IconBinoculars.vue";
import { elasticSearchMoviesAndShows } from "@/api";
import type { IAutocompleteResult } from "@/interfaces/IAutocompleteSearch";
import { trackCommand, getCommandScore } from "@/utils/commandTracking";
@@ -181,10 +180,6 @@
}
> = {
home: { icon: IconMovie, description: "Browse movies and TV shows" },
discover: {
icon: IconBinoculars,
description: "Discover movies by category"
},
activity: { icon: IconActivity, description: "View Plex server activity" },
profile: { icon: IconProfile, description: "Manage your profile" },
"requests-list": null,

View File

@@ -29,7 +29,7 @@
import { ref, computed } from "vue";
import IconKey from "@/icons/IconKey.vue";
import IconEmail from "@/icons/IconEmail.vue";
import IconSearch from "@/icons/IconSearch.vue";
import IconBinoculars from "@/icons/IconBinoculars.vue";
import type { Ref } from "vue";
interface Props {
@@ -53,7 +53,7 @@
const inputIcon = computed(() => {
if (props.type === "password") return IconKey;
if (props.type === "email") return IconEmail;
if (props.type === "torrents") return IconSearch;
if (props.type === "torrents") return IconBinoculars;
return false;
});

View File

@@ -91,8 +91,7 @@ export function usePlexAuth() {
function setPlexAuthCookie(authToken: string) {
const expires = new Date();
expires.setDate(expires.getDate() + 30);
const domain = window.location.hostname;
document.cookie = `plex_auth_token=${authToken}; domain=.${domain}; path=/; expires=${expires.toUTCString()}; SameSite=Strict`;
document.cookie = `plex_auth_token=${authToken}; path=/; expires=${expires.toUTCString()}; SameSite=Strict`;
}
// Get cookie

View File

@@ -22,9 +22,9 @@ function applyTheme(theme: Theme) {
}
export function useTheme() {
const savedTheme = computed(
() => (localStorage.getItem("theme-preference") as Theme) || "auto"
);
const savedTheme = computed(() => {
return (localStorage.getItem("theme-preference") as Theme) || "auto";
});
function initTheme() {
const theme = savedTheme.value;

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.5 18h-2.8562L21.9 17.3719c0.9875-0.4938 1.3875-1.6969 0.8937-2.6813L16.9969 3.1031c-0.2375-0.4781-0.65-0.8343-1.1563-1.0031-0.5062-0.1687-1.05-0.1312-1.525 0.1094l-2.2125 1.1062c-0.9875 0.4938-1.3875 1.6969-0.8937 2.6813V6H7V3c0-0.5531-0.4469-1-1-1H2C1.4469 2 1 2.4469 1 3v14c0 0.5531 0.4469 1 1 1H0.5C0.225 18 0 18.225 0 18.5v3C0 21.775 0.225 22 0.5 22h23c0.275 0 0.5-0.225 0.5-0.5v-3c0-0.275-0.225-0.5-0.5-0.5zM18.7906 16.6906 14.775 8.6562 16.9875 7.55l4.0156 8.0344zM15.2094 3.9969l1.3281 2.6594-2.2125 1.1062-1.3281-2.6594zM17.0031 17.5844c0.075 0.1531 0.1688 0.2906 0.2782 0.4156H12c0.5531 0 1-0.4469 1-1V9.5812zM5 16H3v-1h2zM5 14H3V6h2zM11 8v8H7V8zM5 4v1H3V4zM23 21H1v-2h22z" />
<rect width="1.999992" height="1.000008" x="7.999992" y="9" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22.5594 5.75v0l-3.6688-4.5625C18.7969 1.0687 18.6531 1 18.5 1h-13C5.35 1 5.2062 1.0687 5.1094 1.1875L1.4406 5.75C1.1656 6.0938 1 6.5281 1 7v3.5C1 10.775 1.225 11 1.5 11H3v10c0 1.1031 0.8969 2 2 2h14c1.1031 0 2-0.8969 2-2V11h1.5c0.2749 0 0.4999-0.225 0.4999-0.5V7c0-0.4719-0.1656-0.9094-0.4405-1.25zM5.7406 2h12.5219l2.4125 3H3.325zM19 21H5V11h14zM3 10V7h18v3z" />
<path d="M9.5 15h5c0.8281 0 1.5-0.6719 1.5-1.5S15.3281 12 14.5 12h-5C8.6719 12 8 12.6719 8 13.5S8.6719 15 9.5 15zM9.5 13h5c0.275 0 0.5 0.225 0.5 0.5S14.775 14 14.5 14h-5C9.225 14 9 13.775 9 13.5S9.225 13 9.5 13z" />
</svg>
</template>

View File

@@ -1,16 +0,0 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
<path d="M0 32h160v64h-160v-64z" />
<path d="M288 32h192v64h-192v-64z" />
<path d="M608 32h160v64h-160v-64z" />
<path d="M192 0h64v224h-64v-224z" />
<path d="M512 0h64v224h-64v-224z" />
<path d="M288 128h192v64h-192v-64z" />
<path
d="M704 128h-96v64h96v512h-640v-512h96v-64h-96c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-512c0-35.3-28.7-64-64-64z"
/>
<path
d="M128 304v320c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16v-320c0-8.8-7.2-16-16-16h-480c-8.8 0-16 7.2-16 16zM160 480h128v128h-128v-128zM448 480v128h-128v-128h128zM320 448v-128h128v128h-128zM480 608v-128h128v128h-128zM608 448h-128v-128h128v128zM288 320v128h-128v-128h128z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M9 17c1.1031 0 2-0.8969 2-2 0-0.3656-0.1-0.7125-0.2719-1.0062L13.0094 10c0.1 0 0.1968-94e-4 0.2937-0.0219l1.8344 2.2938C15.05 12.4969 15 12.7438 15 13c0 1.1032 0.8969 2 2 2s2-0.8969 2-2c0-0.4875-0.175-0.9375-0.4688-1.2843l2.8156-7.7469C22.2843 3.8032 22.9999 2.9844 22.9999 2c0-1.1031-0.8968-2-1.9999-2-1.1032 0-2 0.8969-2 2 0 0.4875 0.175 0.9375 0.4687 1.2844l-2.8 7.7-1.8063-2.2563C14.9499 8.5031 15 8.2563 15 8c0-1.1031-0.8969-2-2-2s-2 0.8969-2 2c0 0.3656 0.1 0.7125 0.2718 1.0063L8.9906 13C7.8906 13.0063 7 13.9 7 15c0 1.1031 0.8969 2 2 2zM17 14c-0.55 0-1-0.45-1-1s0.45-1 1-1 1 0.45 1 1-0.45 1-1 1zM21 1c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM13 7c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM9 14c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1z" />
<path d="M23.7063 20.2938l-2.25-2.25-1.4157 1.4156 0.5438 0.5437H4V3.4156l0.5438 0.5438 1.4156-1.4156-2.25-2.25c-0.3906-0.3907-1.025-0.3907-1.4156 0l-2.25 2.25 1.4156 1.4156L2 3.4156V21c0 0.5531 0.4469 1 1 1h17.5844l-0.5437 0.5438 1.4156 1.4156 2.25-2.25c0.3906-0.3937 0.3906-1.025 0-1.4156z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22.5438 3.0438 7 18.5844 1.4563 13.0438 0.0438 14.4563l6.25 6.25C6.4875 20.9 6.7438 21 7 21s0.5125-0.0968 0.7062-0.2937l16.25-16.25z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M8.2 2.9 9.8938 4.1687 9.0375 6.3125c-0.0812 0.2-0.0218 0.4281 0.1406 0.5656 0.0938 0.0781 0.2094 0.1188 0.3251 0.1188 0.0874 0 0.1781-0.025 0.2562-0.0719l2.2437-1.3469 2.2438 1.3469c0.1844 0.1094 0.4156 0.0906 0.5781-0.0469s0.2219-0.3625 0.1438-0.5625l-0.8344-2.15 1.6687-1.2719c0.1719-0.1312 0.2407-0.3531 0.1719-0.5562C15.9063 2.1375 15.7156 2 15.5 2h-1.9719L12.425 0.2344C12.3312 0.0875 12.1719 0 12 0s-0.3312 0.0875-0.425 0.2344L10.4719 2H8.5C8.2844 2 8.0938 2.1375 8.025 2.3406 7.9563 2.5469 8.0281 2.7719 8.2 2.9zM10.75 3c0.1719 0 0.3312-0.0875 0.425-0.2344L12 1.4438l0.825 1.3218C12.9156 2.9125 13.0781 3 13.25 3h0.7687l-0.7906 0.6031C13.05 3.7375 12.9844 3.975 13.0656 4.1813l0.4407 1.1406-1.25-0.75C12.1781 4.525 12.0875 4.5 12 4.5s-0.1781 0.025-0.2562 0.0719l-1.2281 0.7375 0.45-1.1219c0.0843-0.2094 0.0156-0.45-0.1657-0.5844L10 3z" />
<path d="M20.4906 21.1281c-1.4406-0.8093-2.6312-1.6531-3.5625-2.5125 0.5344-0.1875 1.0688-0.4406 1.5938-0.7625 0.2906-0.1781 0.4719-0.4906 0.4781-0.8343 63e-4-0.3438-0.1594-0.6625-0.4437-0.8532-1.2969-0.8656-2.2907-1.7218-2.9688-2.5625 0.3906-0.2187 0.775-0.5281 1.1438-0.925 0.1875-0.2 0.2843-0.4687 0.2687-0.7406s-0.1437-0.5281-0.35-0.7031c-1.5281-1.2969-2.8281-2.9219-3.7625-4.7031-0.1718-0.3282-0.5125-0.5344-0.8843-0.5344-0.3719 0-0.7125 0.2062-0.8844 0.5344-0.9344 1.7781-2.2344 3.4062-3.7625 4.7031-0.2094 0.1781-0.3344 0.4312-0.35 0.7031s0.0813 0.5406 0.2688 0.7406c0.3719 0.3969 0.7531 0.7063 1.1437 0.925C7.7407 14.4406 6.747 15.3 5.4501 16.1656c-0.2844 0.1907-0.4532 0.5125-0.4438 0.8532 63e-4 0.3406 0.1875 0.6562 0.4781 0.8343 0.525 0.3188 1.0563 0.575 1.5907 0.7625-0.9313 0.8594-2.1219 1.7032-3.5625 2.5125-0.3406 0.1907-0.5375 0.5625-0.5063 0.9532 0.0313 0.3906 0.2875 0.725 0.6532 0.8593C6.3532 23.9219 8.922 24 12.0032 24c3.0781 0 5.6469-0.0781 8.3438-1.0594 0.3656-0.1343 0.6218-0.4687 0.6531-0.8593 0.0281-0.3907-0.1688-0.7625-0.5095-0.9532zM12 22c-2.0906 0-3.8437-0.0344-5.5312-0.3562 1.3999-0.9657 2.4968-1.9657 3.3218-3.0313 0.2281-0.2937 0.275-0.6875 0.1188-1.025-0.1563-0.3375-0.4813-0.5625-0.85-0.5843-0.3594-0.0219-0.7219-0.0875-1.0875-0.1969 1.3906-1.1032 2.3531-2.2125 2.925-3.3594 0.1593-0.3219 0.1375-0.7031-0.0625-1.0031-0.2001-0.3-0.5438-0.4688-0.9032-0.4406-0.1249 93e-4-0.2531-0.0157-0.3781-0.0657 0.9157-0.8968 1.7407-1.8968 2.4469-2.9718 0.7062 1.0718 1.5312 2.075 2.4469 2.9718-0.125 0.05-0.2531 0.075-0.3781 0.0657-0.3594-0.025-0.7032 0.1437-0.9032 0.4406s-0.225 0.6812-0.0625 1.0031c0.5719 1.1469 1.5344 2.2563 2.925 3.3594-0.3656 0.1094-0.7281 0.175-1.0875 0.1969-0.3687 0.0218-0.6968 0.2468-0.85 0.5843-0.1531 0.3375-0.1062 0.7313 0.1188 1.025 0.825 1.0656 1.9219 2.0656 3.3219 3.0313C15.8438 21.9656 14.0906 22 12 22z" />
</svg>
</template>

View File

@@ -1,8 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.5 8H10.9375L22.625 4.9844c0.2656-0.0688 0.4281-0.3375 0.3594-0.6063l-1-4c-0.0656-0.2688-0.3375-0.4313-0.6063-0.3625L3.85 4.3968C3.4594 4.1468 2.9969 4 2.5 4 1.1219 4 0 5.1218 0 6.5V22c0 1.1031 0.8969 2 2 2h20c1.1031 0 2-0.8969 2-2V8.5C24 8.225 23.775 8 23.5 8zM8.5 12C8.8156 11.5812 9 11.0625 9 10.5S8.8125 9.4188 8.5 9h3.7938l-3 3zM13.7063 9h3.5843l-3 3h-3.5844zM18.7062 9h3.5844l-3 3h-3.5844zM23 9.7062V12h-2.2938zM6.5 8C5.6719 8 5 7.3281 5 6.5 5 6.0344 4.8719 5.6 4.65 5.2281l2.7687-0.6937 3.8875 2.3375L6.9375 8zM15.2719 5.85 12.6687 6.5219 8.7938 4.1938l2.6281-0.6563zM12.7906 3.1938l2.6281-0.6563 3.8125 2.2906L16.6281 5.5zM21.8937 4.1406 20.5906 4.4781l-3.8-2.2843 4.3438-1.0875zM1 6.5C1 5.6719 1.6719 5 2.5 5S4 5.6719 4 6.5C4 7.8781 5.1219 9 6.5 9 7.3281 9 8 9.6719 8 10.5S7.3281 12 6.5 12h-4C1.6719 12 1 11.3281 1 10.5zM2 22v-9.05C2.1625 12.9844 2.3281 13 2.5 13H22v9z" />
<path d="M3 11c0.55 0 1-0.45 1-1S3.55 9 3 9 2 9.45 2 10s0.45 1 1 1z" />
<path d="M9.2375 20.925C9.3188 20.975 9.4094 21 9.5 21c0.075 0 0.1531-0.0187 0.225-0.0531l6-3C15.8937 17.8625 16 17.6875 16 17.5s-0.1063-0.3625-0.275-0.4469l-6-3c-0.1563-0.0781-0.3375-0.0687-0.4875 0.0219C9.0906 14.1656 9 14.3281 9 14.5v6c0 0.1718 0.0906 0.3343 0.2375 0.425zM10 15.3094 14.3812 17.5 10 19.6906z" />
</svg>
</template>

View File

@@ -1,8 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M19 3h-1v2h1v17H5V5h1V3H5C3.8969 3 3 3.8969 3 5v17c0 1.1031 0.8969 2 2 2h14c1.1031 0 2-0.8969 2-2V5c0-1.1031-0.8969-2-2-2z" />
<path d="M7.5 5h9C16.775 5 17 4.775 17 4.5v-1C17 2.6719 16.3281 2 15.5 2h-0.5594c-0.1187-0.4938-0.4156-0.9469-0.8594-1.3031C13.5187 0.2469 12.7812 0 12 0c-0.7813 0-1.5188 0.2469-2.0813 0.6969C9.4718 1.0531 9.1781 1.5062 9.0593 2H8.5C7.6718 2 7 2.6719 7 3.5v1C7 4.775 7.225 5 7.5 5zM8 3.5C8 3.225 8.225 3 8.5 3h1C9.775 3 10 2.775 10 2.5c0-0.3781 0.1937-0.7437 0.5437-1.0219C10.9281 1.1688 11.4469 1 12 1s1.0718 0.1688 1.4562 0.4781C13.8062 1.7594 14 2.1219 14 2.5 14 2.775 14.225 3 14.5 3h1C15.775 3 16 3.225 16 3.5V4H8z" />
<path d="M17 8H7V7h10zM13 10H7v1h6zM17 12H7v1h10zM15 14H7v1h8zM17 16H7v1h10zM16 18H7v1h9z" />
</svg>
</template>

View File

@@ -1,13 +0,0 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
<path
d="M737.8 234.5c-19.3-45.7-47-86.8-82.3-122-35.3-35.3-76.3-62.9-122-82.3-47.4-20-97.7-30.2-149.5-30.2s-102.1 10.2-149.5 30.2c-45.7 19.3-86.8 47-122 82.3-35.3 35.3-62.9 76.3-82.3 122-20 47.4-30.2 97.7-30.2 149.5s10.2 102.1 30.2 149.5c19.3 45.7 47 86.8 82.3 122 35.3 35.3 76.3 62.9 122 82.3 47.4 20 97.7 30.2 149.5 30.2s102.1-10.2 149.5-30.2c45.7-19.3 86.8-47 122-82.3 35.3-35.3 62.9-76.3 82.3-122 20-47.4 30.2-97.7 30.2-149.5s-10.2-102.1-30.2-149.5zM384 704c-176.4 0-320-143.6-320-320s143.6-320 320-320c176.4 0 320 143.6 320 320s-143.6 320-320 320z"
/>
<path
d="M384 96c-76.9 0-149.3 30-203.6 84.4s-84.4 126.7-84.4 203.6 30 149.3 84.4 203.6c54.3 54.4 126.7 84.4 203.6 84.4s149.3-30 203.6-84.4c54.4-54.3 84.4-126.7 84.4-203.6s-30-149.3-84.4-203.6c-54.3-54.4-126.7-84.4-203.6-84.4zM384 640c-141.2 0-256-114.8-256-256s114.8-256 256-256c141.2 0 256 114.8 256 256s-114.8 256-256 256z"
/>
<path
d="M520.8 225.7l-192 96c-3.1 1.5-5.6 4.1-7.2 7.2l-96 192c-3.1 6.2-1.9 13.6 3 18.5 3.1 3.1 7.2 4.7 11.3 4.7 2.4 0 4.9-0.6 7.2-1.7l192-96c3.1-1.5 5.6-4.1 7.2-7.2l96-192c3.1-6.2 1.9-13.6-3-18.5s-12.3-6.1-18.5-3zM340.4 363l64.6 64.6-129.2 64.6 64.6-129.2zM427.6 405l-64.6-64.6 129.2-64.6-64.6 129.2z"
/>
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<polygon points="20.956242 4.456242 19.543734 3.043734 11.999977 10.584352 4.456219 3.043734 3.043711 4.456242 10.584328 12 3.043711 19.543758 4.456219 20.956266 11.999977 13.415648 19.543734 20.956266 20.956242 19.543758 13.415625 12" />
</svg>
</template>

View File

@@ -1,11 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M24 6.5C24 5.6719 23.3281 5 22.5 5S21 5.6719 21 6.5c0 0.3063 0.0938 0.5937 0.2531 0.8313-0.1187 0.1093-0.2344 0.2187-0.35 0.3281-1.5968 1.4969-2.7062 2.5375-4.5125 3.175-1.075-1.5625-2.1218-3.6719-3.1218-6.2875C13.7156 4.1781 14 3.6219 14 3c0-1.1031-0.8969-2-2-2s-2 0.8969-2 2c0 0.6219 0.2844 1.1781 0.7313 1.5438-1 2.6156-2.0469 4.725-3.1219 6.2875-1.8063-0.6375-2.9156-1.6782-4.5125-3.175-0.1156-0.1063-0.2312-0.2157-0.35-0.3282C2.9063 7.0938 3 6.8063 3 6.5 3 5.6719 2.3282 5 1.5 5 0.6719 5 0 5.6719 0 6.5 0 7.1531 0.4188 7.7094 1.0032 7.9156 0.9907 8.0594 1.0094 8.2094 1.0657 8.3531 2.1282 11.1656 2.8001 14.2125 3.0063 17H2.0001v2h1.0718c-31e-4 0.3656-0.0187 0.725-0.0406 1.075-0.6 0.1969-1.0312 0.7625-1.0312 1.425 0 0.8281 0.6718 1.5 1.5 1.5h17c0.8281 0 1.5-0.6719 1.5-1.5 0-0.6625-0.4313-1.2281-1.0282-1.425-0.0218-0.35-0.0375-0.7094-0.0406-1.075h1.0688v-2h-1.0063c0.2063-2.7875 0.8781-5.8344 1.9406-8.6469 0.0532-0.1437 0.0719-0.2937 0.0625-0.4375C23.5813 7.7094 24.0001 7.1531 24 6.5zM12 2c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM1.5 6C1.775 6 2 6.225 2 6.5S1.775 7 1.5 7 1 6.775 1 6.5 1.225 6 1.5 6zM20.5 22h-17C3.225 22 3 21.775 3 21.5S3.225 21 3.5 21h17c0.275 0 0.5 0.225 0.5 0.5S20.775 22 20.5 22zM18.9625 20H5.0375c0.0188-0.3281 0.0281-0.6594 0.0313-1h13.8593c63e-4 0.3406 0.0157 0.6719 0.0344 1zM5.0094 17c-0.1313-1.8906-0.4594-3.8875-0.9688-5.8625 1.0344 0.7812 2.1688 1.4063 3.6969 1.825 0.3937 0.1094 0.8156-0.0344 1.0625-0.3594 1.1281-1.4875 2.1844-3.4125 3.2031-5.8468 1.0188 2.4343 2.075 4.3593 3.2032 5.8468 0.2468 0.325 0.6656 0.4688 1.0624 0.3594 1.5282-0.4187 2.6625-1.0437 3.6969-1.825-0.5094 1.9719-0.8406 3.9719-0.9687 5.8625zM22.5 7C22.225 7 22 6.775 22 6.5S22.225 6 22.5 6 23 6.225 23 6.5 22.775 7 22.5 7z" />
<path d="M11 15.5c0 0.2761-0.2239 0.5-0.5 0.5S10 15.7761 10 15.5 10.2239 15 10.5 15 11 15.2239 11 15.5z" />
<path d="M14 15.5c0 0.2761-0.2239 0.5-0.5 0.5S13 15.7761 13 15.5 13.2239 15 13.5 15 14 15.2239 14 15.5z" />
<path d="M17 15.5c0 0.2761-0.2239 0.5-0.5 0.5S16 15.7761 16 15.5 16.2239 15 16.5 15 17 15.2239 17 15.5z" />
<path d="M8 15.5C8 15.7761 7.7761 16 7.5 16S7 15.7761 7 15.5 7.2239 15 7.5 15 8 15.2239 8 15.5z" />
<path d="M12 12c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 13c0 0 0 0 0 0z" />
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<template>
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.0562 7.3281C22.4531 5.9 21.5875 4.6156 20.4844 3.5156 19.3812 2.4125 18.1 1.55 16.6719 0.9438 15.1906 0.3187 13.6187 0 12 0S8.8094 0.3187 7.3281 0.9438C5.9 1.5469 4.6156 2.4125 3.5156 3.5156 2.4125 4.6188 1.55 5.9 0.9438 7.3281 0.3188 8.8094 0 10.3813 0 12s0.3188 3.1906 0.9438 4.6719c0.6031 1.4281 1.4687 2.7125 2.5718 3.8125C4.6188 21.5875 5.9 22.45 7.3281 23.0562 8.8094 23.6813 10.3813 24 12 24s3.1906-0.3187 4.6719-0.9438c1.4281-0.6031 2.7125-1.4687 3.8125-2.5718 1.1031-1.1032 1.9656-2.3844 2.5718-3.8125C23.6813 15.1906 24 13.6187 24 12s-0.3187-3.1906-0.9438-4.6719zM12 22C6.4875 22 2 17.5125 2 12S6.4875 2 12 2 22 6.4875 22 12 17.5125 22 12 22z"
/>
<path
d="M12 3C9.5969 3 7.3344 3.9375 5.6375 5.6375S3 9.5969 3 12s0.9375 4.6656 2.6375 6.3625C7.3344 20.0625 9.5969 21 12 21s4.6656-0.9375 6.3625-2.6375C20.0625 16.6656 21 14.4031 21 12s-0.9375-4.6656-2.6375-6.3625C16.6656 3.9375 14.4031 3 12 3zM12 20c-4.4125 0-8-3.5875-8-8s3.5875-8 8-8 8 3.5875 8 8-3.5875 8-8 8z"
/>
<path
d="M16.275 7.0531l-6 3c-0.0969 0.0469-0.175 0.1281-0.225 0.225l-3 6c-0.0969 0.1938-0.0594 0.425 0.0937 0.5782 0.0969 0.0968 0.2251 0.1468 0.3532 0.1468 0.075 0 0.1531-0.0187 0.225-0.0531l6-3c0.0969-0.0469 0.175-0.1281 0.225-0.225l3-6c0.0969-0.1938 0.0594-0.425-0.0938-0.5781C16.7 6.9937 16.4688 6.9563 16.275 7.0531zM10.6375 11.3438l2.0188 2.0187-4.0376 2.0187zM13.3625 12.6563l-2.0187-2.0188 4.0375-2.0187z"
/>
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M7 5c1.1031 0 2-0.8969 2-2S8.1031 1 7 1 5 1.8969 5 3 5.8969 5 7 5zM7 2c0.55 0 1 0.45 1 1S7.55 4 7 4 6 3.55 6 3 6.45 2 7 2z" />
<path d="M21.0187 11.8031 19.1813 21h-3.3626l-1.8406-9.1969-1.9625 0.3938 2 10C14.1094 22.6656 14.5188 23 14.9969 23h5c0.4781 0 0.8875-0.3375 0.9812-0.8031l2-10z" />
<path d="M16 14.5c0 0.8281 0.6719 1.5 1.5 1.5s1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5-1.5 0.6719-1.5 1.5zM18 14.5c0 0.275-0.225 0.5-0.5 0.5S17 14.775 17 14.5 17.225 14 17.5 14 18 14.225 18 14.5z" />
<path d="M17 11.5V12h1v-0.5c0-0.2469-0.0156-0.8969-0.1625-1.5719-0.1969-0.9-0.5562-1.5218-1.0656-1.85L16.35 7.8063 15.8094 8.6469l0.4218 0.2719C16.7625 9.2625 17 10.4625 17 11.5z" />
<path d="M9.9875 9.9219c0.1562 0.0937 0.3344 0.1406 0.5125 0.1406 0.1812 0 0.3656-0.05 0.525-0.15L14.775 7.6l-1.05-1.7031-3.2062 1.9781C9.8906 7.4344 8.7687 6.5656 7.7719 5.3625c0 0 0 0 0 0s0 0 0 0v0C7.6844 5.2562 7.575 5.1687 7.45 5.1062 7.1125 4.9375 6.7063 4.9719 6.4031 5.2L3.0625 7.7C2.9406 7.7906 2.8406 7.9094 2.7719 8.0469l-1.9094 3.75 1.7813 0.9062 1.8031-3.5437 1.5562-1.1625v3.9187l-0.9469 5.6719-3.1 4.0563 1.5875 1.2156 3.25-4.25c0.1-0.1313 0.1657-0.2813 0.1907-0.4437l0.5562-3.3219L8.9531 17.2 8.0125 22.8375l1.9719 0.3281 1-6c0.0406-0.2343-63e-4-0.475-0.1282-0.6781L8 11.7219V8.4407c1.0687 0.9281 1.9343 1.45 1.9875 1.4812z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.0562 7.3281C22.4531 5.9 21.5875 4.6156 20.4844 3.5156 19.3812 2.4125 18.1 1.55 16.6719 0.9438 15.1906 0.3187 13.6187 0 12 0S8.8094 0.3187 7.3281 0.9438C5.9 1.5469 4.6156 2.4125 3.5156 3.5156 2.4125 4.6188 1.55 5.9 0.9438 7.3281 0.3188 8.8094 0 10.3813 0 12s0.3188 3.1906 0.9438 4.6719c0.6031 1.4281 1.4687 2.7125 2.5718 3.8125C4.6188 21.5875 5.9 22.45 7.3281 23.0562 8.8094 23.6813 10.3813 24 12 24s3.1906-0.3187 4.6719-0.9438c1.4281-0.6031 2.7125-1.4687 3.8125-2.5718 1.1031-1.1032 1.9656-2.3844 2.5718-3.8125C23.6813 15.1906 24 13.6187 24 12s-0.3187-3.1906-0.9438-4.6719zM20.5781 6.8625C20.45 6.925 20.3219 7.0062 20.1969 7.1031l-0.0657 0.05-0.0468 0.0688c-0.4688 0.6937-1.2031 0.9062-1.6907 0.7562-0.2625-0.0812-0.3968-0.2406-0.3968-0.4781 0-0.675-0.3594-1.2-0.6469-1.625C17.1 5.5063 16.95 5.2719 17 5.1c0.0438-0.15 0.2688-0.4594 1.2219-0.925 0.9375 0.7469 1.7375 1.6594 2.3562 2.6875zM12 2c0.2031 0 0.4031 62e-4 0.6 0.0187-0.1 0.0657-0.2187 0.1188-0.3531 0.1813-0.3907 0.1781-0.9282 0.425-1.2094 1.1125l-94e-4 0.0187-62e-4 0.0188c-0.6438 2.0375-1.5063 2.8375-2.0782 3.3656-0.4094 0.3781-0.7625 0.7063-0.7687 1.25-63e-4 0.4719 0.2468 0.9719 0.9312 1.8438 0.5469 0.6968 1.0719 1.05 1.6063 1.0875 0.675 0.0437 1.15-0.4188 1.5343-0.7906 0.1938-0.1875 0.3938-0.3844 0.5438-0.4344 0.0406-0.0125 0.1312-0.0438 0.3594 0.1812 0.8312 0.8313 1.5093 1.05 2.0062 1.2125 0.4594 0.15 0.6688 0.2188 0.9031 0.6656 0.3907 0.7407 0.9813 1.0282 1.4125 1.2375 0.4719 0.2282 0.5313 0.2876 0.5313 0.5313 0 0.1594 63e-4 0.3312 94e-4 0.5094 0.0187 0.6375 0.0437 1.6031-0.2438 1.9C17.7281 15.9531 17.6593 16 17.5031 16c-0.9969 0-1.3969 0.8875-1.6625 1.475-0.075 0.1687-0.2 0.4406-0.2812 0.5281-0.6532-0.0906-1.4532 0.6563-2.6938 1.8688-0.275 0.2687-0.6375 0.625-0.925 0.875 0.0313-0.3313 0.1063-0.8282 0.2688-1.5344 0.2187-0.9594 0.5312-1.9938 0.7562-2.5156 0.1469-0.3407 0.1875-0.8719-0.4562-1.4563-0.3469-0.3125-0.825-0.5937-1.2907-0.8656-0.3343-0.1938-0.6468-0.3781-0.8875-0.5594-0.2687-0.2031-0.3187-0.3062-0.325-0.325 0-0.1844 0.0344-0.3937 0.0656-0.6156 0.1282-0.8344 0.3188-2.0969-1.3656-2.8344-0.175-0.0781-0.3594-0.15-0.5344-0.2187-1.4093-0.5563-2.8625-1.1313-3.1218-5C6.8437 3.0781 9.2968 2 12 2zM2.8625 16.0594c0.5969 0.1031 1.0312-0.3344 1.3531-0.6563 0.1063-0.1062 0.3281-0.3281 0.4188-0.35 0.0437 0.0188 0.3593 0.1875 0.9031 1.625 0.3187 0.8438 0.3344 1.7125 0.35 2.7157 31e-4 0.1687 62e-4 0.3406 94e-4 0.5218-1.3063-1.0093-2.3563-2.3343-3.0344-3.8562zM6.9219 20.6125c-0.025-0.4281-0.0313-0.8375-0.0406-1.2375-0.0188-1.0531-0.0344-2.0437-0.4157-3.0531-0.5562-1.4688-1.0281-2.1188-1.6312-2.25-0.5844-0.125-1.0156 0.3094-1.3313 0.625-0.1281 0.1281-0.3906 0.3906-0.475 0.375-0.0344-63e-4-0.3344-0.1031-0.8718-1.3063C2.0531 13.1906 2 12.6031 2 12 2 9.6719 2.8 7.525 4.1406 5.825 4.3219 7.1688 4.6812 8.2063 5.225 8.9719c0.7531 1.0625 1.7219 1.4437 2.575 1.7812 0.175 0.0688 0.3406 0.1344 0.5031 0.2063 0.9813 0.4281 0.9063 0.9312 0.7782 1.7656-0.0375 0.2531-0.0782 0.5156-0.0782 0.775 0 0.7406 0.8313 1.225 1.7094 1.7407 0.4188 0.2468 0.8531 0.4999 1.1219 0.7437 0.2312 0.2094 0.2187 0.2969 0.2094 0.3187-0.2625 0.6094-0.6125 1.775-0.8469 2.8376-0.1281 0.5749-0.2125 1.0875-0.2531 1.4843-0.0563 0.5844-0.0125 0.9313 0.1437 1.1594 0.0563 0.0813 0.1313 0.1469 0.2156 0.1938-1.5906-0.1125-3.0812-0.5969-4.3812-1.3657zM12 22c-0.05 0-0.0969 0-0.1469 0 0.3938-0.1406 0.875-0.6 1.7063-1.4125 0.4-0.3906 0.8125-0.7938 1.1781-1.1062 0.4594-0.3907 0.6531-0.4688 0.7062-0.4813 0.2157 0.0312 0.5907 94e-4 0.9094-0.3937 0.1625-0.2032 0.275-0.4532 0.3938-0.7188 0.2812-0.625 0.45-0.8844 0.7531-0.8844 0.3906 0 0.7312-0.1375 0.9812-0.3937 0.5844-0.6 0.5532-1.675 0.5282-2.625C19.0031 13.8125 19 13.65 19 13.5031c0-0.9031-0.6344-1.2093-1.0969-1.4312-0.3719-0.1813-0.725-0.35-0.9625-0.8-0.425-0.8094-0.9625-0.9844-1.4812-1.1531C15.0156 9.975 14.5125 9.8094 13.85 9.15c-0.4407-0.4406-0.9063-0.5843-1.3844-0.4219C12.1 8.85 11.8187 9.125 11.5437 9.3938c-0.2781 0.2687-0.5375 0.525-0.7687 0.5093-0.1313-93e-4-0.4188-0.1125-0.8875-0.7062-0.4782-0.6094-0.7219-1.0156-0.7188-1.2094 31e-4-0.1125 0.1719-0.275 0.45-0.5312 0.6063-0.5625 1.6219-1.5032 2.3469-3.775 0.125-0.2938 0.3281-0.3969 0.6937-0.5657 0.375-0.1718 0.8438-0.3875 1.1125-0.95 1.2781 0.2313 2.4719 0.7032 3.5313 1.3688-0.7406 0.4219-1.1313 0.8313-1.2656 1.2969-0.1844 0.6343 0.1718 1.1562 0.4843 1.6156 0.2438 0.3562 0.4751 0.6937 0.4751 1.0594 0 0.675 0.4218 1.225 1.1031 1.4343 0.1844 0.0563 0.3906 0.0875 0.6062 0.0875 0.7188 0 1.5594-0.3344 2.1563-1.1656 0.0656-0.0469 0.1281-0.0812 0.1844-0.1094C21.6594 9.0375 22 10.4781 22 12c0 5.5125-4.4875 10-10 10z" />
</svg>
</template>

View File

@@ -1,8 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M18.75 5.1719c-0.7969-1.5188-1.725-2.7313-2.7562-3.6031C14.7594 0.5281 13.4187 0 12 0S9.2406 0.5281 8.0063 1.5688C6.9719 2.4406 6.0438 3.6531 5.25 5.1719 3.8625 7.8188 3 11.2 3 14c0 2.7125 0.8438 5.2031 2.3719 7.0094 0.8 0.9438 1.7625 1.6812 2.8594 2.1906C9.3781 23.7313 10.6437 24 12 24s2.6219-0.2687 3.7688-0.8c1.0968-0.5093 2.0593-1.2468 2.8593-2.1906C20.1563 19.2031 21 16.7125 21 14c0-2.8-0.8625-6.1812-2.25-8.8281zM17.6594 18.95l-0.8063-0.8063c-0.1937-0.1937-0.5125-0.1937-0.7062 0l-1.6469 1.65-1.6469-1.6468c-0.1937-0.1938-0.5125-0.1938-0.7062 0L10.5 19.7937 8.8531 18.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0l-1.3875 1.3875c-0.5313-0.6625-0.9563-1.4438-1.2532-2.3188l0.6407 0.6406c0.1937 0.1938 0.5125 0.1938 0.7062 0L8.5 16.2094l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L12.5 16.2094l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L16.5 16.2094l1.6469 1.6468c0.0219 0.0219 0.0469 0.0438 0.0719 0.0594-0.1625 0.3656-0.35 0.7125-0.5594 1.0344zM17.4094 6.9906c0.1969 0.4375 0.3781 0.8907 0.5406 1.35L17.5 8.7938 15.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L13.5 8.7938 11.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L9.5 8.7938 7.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L6.1031 8.1875c0.3844-1.0594 0.8625-2.0719 1.4188-2.9594l1.625 1.625c0.1937 0.1938 0.5125 0.1938 0.7062 0L11.5 5.2062l1.6469 1.6469c0.1937 0.1938 0.5125 0.1938 0.7062 0L15.5 5.2062l1.6469 1.6469c0.075 0.075 0.1656 0.1219 0.2625 0.1375zM18.875 12.1719c0.025 0.1906 0.0469 0.3812 0.0625 0.5687C18.8531 12.8969 18.6875 13 18.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5c0.15 0 0.2844 0.0656 0.375 0.1719zM18.5 16.7937l-1.6469-1.6468c-0.1937-0.1938-0.5125-0.1938-0.7062 0L14.5 16.7937l-1.6469-1.6468c-0.1938-0.1938-0.5125-0.1938-0.7062 0L10.5 16.7937 8.8531 15.1469c-0.1938-0.1938-0.5125-0.1938-0.7062 0L6.5 16.7937 5.0875 15.3812C5.0312 14.9375 5 14.4781 5 14.0031c0-0.3625 0.0156-0.7375 0.05-1.1219C5.2187 13.525 5.8031 14 6.5 14 7.3281 14 8 13.3281 8 12.5S7.3281 11 6.5 11c-0.5719 0-1.0688 0.3218-1.3219 0.7906 0.0906-0.5906 0.2125-1.1938 0.3656-1.7938 0.1125-93e-4 0.2219-0.0593 0.3094-0.1437L7.5 8.2062 9.1469 9.8531c0.1937 0.1938 0.5125 0.1938 0.7062 0L11.5 8.2062l1.6469 1.6469c0.1937 0.1938 0.5125 0.1938 0.7062 0L15.5 8.2062l1.6469 1.6469C17.2437 9.95 17.3719 10 17.5 10s0.2562-0.05 0.3531-0.1469l0.4407-0.4406c0.1531 0.5312 0.2843 1.0687 0.3906 1.6C18.625 11.0062 18.5625 11 18.5 11c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5c0.175 0 0.3438-0.0312 0.5-0.0844 0 0.0281 0 0.0563 0 0.0844 0 0.9344-0.1125 1.8125-0.3281 2.6187zM6 12.5C6 12.225 6.225 12 6.5 12S7 12.225 7 12.5 6.775 13 6.5 13 6 12.775 6 12.5zM12 2c1.3437 0 2.5625 0.7844 3.5969 2.0094-0.1563-0.0313-0.3281 0.0156-0.45 0.1375L13.5 5.7937 11.8531 4.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L9.5 5.7937 8.1 4.3938C9.1906 2.95 10.5219 2 12 2zM12 22c-1.8031 0-3.3687-0.6312-4.5594-1.7344L8.5 19.2063l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L12.5 19.2063l1.6469 1.6468C14.2438 20.95 14.3719 21 14.5 21c0.1282 0 0.2563-0.05 0.3532-0.1469L16.5 19.2063l0.5532 0.5531C15.8157 21.175 14.0657 22 12 22z" />
<path d="M10.5 11C9.6719 11 9 11.6719 9 12.5S9.6719 14 10.5 14 12 13.3281 12 12.5 11.3281 11 10.5 11zM10.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
<path d="M14.5 11c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5 1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5zM14.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M1.5531 0.1062C1.6969 0.0344 1.85 0 2.0062 0H14.5C14.775 0 15 0.225 15 0.5V5h-1V1H3.6656L10.6 6.2C10.8531 6.3875 11 6.6844 11 7v10h3v-4h1v4.5c0 0.275-0.225 0.5-0.5 0.5H11v5c0 0.3781-0.2125 0.725-0.5531 0.8937C10.3063 23.9656 10.1532 24 10 24c-0.2125 0-0.425-0.0688-0.6-0.2l-8-6C1.1469 17.6125 1 17.3156 1 17V1c0-0.3782 0.2125-0.725 0.5531-0.8938zM3 16.5 9 21V7.5L3 3z" />
<path d="M13 10V8h6.5844l-2.5438-2.5437 1.4157-1.4157 4.2499 4.25c0.3907 0.3907 0.3907 1.025 0 1.4157l-4.2499 4.25-1.4157-1.4157L19.5844 10z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.7063 11.2938l-6.2501-6.25-1.4156 1.4156L21.5844 12l-5.5407 5.5437 1.4157 1.4157 6.25-6.25c0.3875-0.3938 0.3875-1.025-31e-4-1.4156z" />
<path d="M6.5437 5.0437l-6.25 6.2501c-0.3906 0.3906-0.3906 1.0249 0 1.4156l6.25 6.25 1.4157-1.4156L2.4156 12 7.9594 6.4563z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M12 2.4156l5.5437 5.5438 1.4157-1.4157-6.25-6.25c-0.3907-0.3906-1.025-0.3906-1.4157 0l-6.25 6.25 1.4157 1.4157z" />
<path d="M12 21.5844 6.4563 16.0437 5.0406 17.4594l6.25 6.25c0.1938 0.1937 0.45 0.2937 0.7063 0.2937 0.2562 0 0.5125-0.0969 0.7062-0.2937l6.25-6.25-1.4156-1.4157z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.5 4h-23C0.225 4 0 4.225 0 4.5v15C0 19.775 0.225 20 0.5 20h23c0.275 0 0.5-0.225 0.5-0.5v-15C24 4.225 23.775 4 23.5 4zM4 5v2H2V5zM2 14h2v2H2zM2 13v-2h2v2zM2 10V8h2v2zM2 19v-2h2v2zM18 18H6V6h12zM22 16h-2v-2h2zM22 13h-2v-2h2zM22 10h-2V8h2zM20 19v-2h2v2zM22 7h-2V5h2z" />
</svg>
</template>

View File

@@ -1,8 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M21 11.5c0-0.2-94e-4-0.3969-0.0281-0.5937-0.1438-2.8844-0.8625-5.3719-2.1-7.2375C17.2812 1.2688 14.9031 0 12 0S6.7188 1.2688 5.1281 3.6688C3.7344 5.7688 3 8.6469 3 12c0 2.7719 0.1781 5.925 1.5031 8.2688 0.6844 1.2093 1.6375 2.1375 2.8344 2.7531 1.2719 0.6563 2.7969 0.975 4.6625 0.975s3.3906-0.3187 4.6625-0.975c1.1969-0.6187 2.15-1.5438 2.8343-2.7531C20.8218 17.925 21 14.7719 21 12c0-0.1687-32e-4-0.3344-63e-4-0.5zM6.7938 4.775C8.0156 2.9344 9.7656 2 12 2c1.3844 0 2.5844 0.3594 3.5844 1.0687C15.225 3.025 14.8625 3 14.5 3c-1.5625 0-3.8063 0.2875-5.6156 1.6656C6.9719 6.1188 6 8.4188 6 11.5 6 13.9813 8.0187 16 10.5 16c2.4812 0 4.5-2.0187 4.5-4.5V11h-1v0.5c0 1.9313-1.5688 3.5-3.5 3.5C8.5687 15 7 13.4313 7 11.5 7 4.975 11.7 4 14.5 4c0.8312 0 1.6625 0.1406 2.4469 0.4094 0.0875 0.1187 0.175 0.2375 0.2593 0.3656 0.2375 0.3563 0.45 0.7406 0.6407 1.1563C16.8687 5.3406 15.7219 5 14.5 5 10.9156 5 8 7.9156 8 11.5 8 12.8781 9.1219 14 10.5 14s2.5-1.1219 2.5-2.5c0-0.8281 0.6719-1.5 1.5-1.5s1.5 0.6719 1.5 1.5c0 3.0313-2.4687 5.5-5.5 5.5-2.975 0-5.4063-2.375-5.4969-5.3312 0.0438-2.8125 0.6625-5.1907 1.7907-6.8938zM17.7531 19.2875C16.7062 21.1375 14.8781 22 12 22s-4.7094-0.8625-5.7531-2.7125c-0.0406-0.0719-0.0782-0.1438-0.1156-0.2156C7.3156 19.6781 8.6344 20 10 20v-1c-1.6187 0-3.1687-0.5156-4.4531-1.4625-0.2063-0.75-0.3375-1.5563-0.4188-2.3813C6.3 16.8719 8.2688 18 10.5 18c3.5844 0 6.5-2.9156 6.5-6.5 0-1.3781-1.1219-2.5-2.5-2.5S12 10.1219 12 11.5c0 0.8281-0.6719 1.5-1.5 1.5S9 12.3281 9 11.5C9 8.4687 11.4687 6 14.5 6c1.5656 0 2.9812 0.6562 3.9844 1.7125C18.825 8.9844 19 10.4281 19 12c0 2.5125-0.1469 5.3437-1.2469 7.2875z" />
<path d="M10 11.5V12h1v-0.5C11 9.5688 12.5688 8 14.5 8H15V7h-0.5C12.0187 7 10 9.0188 10 11.5z" />
<path d="M16 7v1c0.4719 0 0.9563 0.3875 1.3313 1.0625C17.7625 9.8406 18 10.8844 18 12c0 3.8531-2.1906 8-7 8v1c2.5031 0 4.5969-1 6.0531-2.8906 0.6375-0.8281 1.1313-1.8 1.4688-2.8844C18.8406 14.1937 19 13.1125 19 12.0031 19 9.1969 17.6813 7 16 7z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M21.9906 3.4062C21.875 2.8 21.2219 2.2906 19.9875 1.85c-0.9281-0.3313-2.1687-0.6063-3.4031-0.7563-1.0375-0.125-1.9781-0.1562-2.7188-0.0875-0.9906 0.0938-1.575 0.3626-1.7875 0.8282C12.0094 1.9875 11.9813 2.1437 12 2.3031v2.3c-0.525 0.1781-1.25 0.3219-2.1531 0.3625C8.1563 5.0437 6.4094 4.7531 5 4.1687V3.7281c0.5969-0.3469 1-0.9937 1-1.7312 0-1.1032-0.8969-2-2-2S2 0.8969 2 2c0 0.7375 0.4031 1.3844 1 1.7313V24h2V12.2469c1.3906 0.5093 2.9344 0.7375 4.3438 0.7375 1.425 0 2.7093-0.2344 3.5468-0.6469 0.6188-0.3031 0.9875-0.6938 1.0969-1.1625C13.9969 11.1375 14 11.1 14 11.0625V9.0187c1.0687-0.0968 2.6656 0.0375 4.1562 0.3626 0.8469 0.1843 1.5719 0.4125 2.0969 0.6562 0.5844 0.275 0.7438 0.4906 0.7531 0.5531 0.0438 0.2375 0.2532 0.4063 0.4907 0.4063 0.0156 0 0.0312 0 0.0468-31e-4 0.2563-0.0251 0.4531-0.2407 0.4531-0.4969V3.5c32e-4-0.0312 0-0.0625-62e-4-0.0938zM4 1c0.55 0 1 0.45 1 1S4.55 3 4 3 3 2.55 3 2 3.45 1 4 1zM12.45 11.4406c-1.5156 0.7438-4.8812 0.7969-7.45-0.2687v-5.925c1.4719 0.5406 3.2125 0.8 4.8938 0.7218 0.8812-0.0406 1.6937-0.175 2.35-0.3875 0.2843-0.0937 0.5375-0.1999 0.7531-0.3187v5.7281c-0.0563 0.1438-0.2563 0.3094-0.5469 0.45zM21 9.3c-0.1-0.0562-0.2062-0.1094-0.3218-0.1656-0.5907-0.2782-1.3907-0.5281-2.3094-0.7282C16.8094 8.0656 15.1719 7.925 14 8.0187V3.875c-31e-4-0.3469-0.1562-0.7781-0.6625-1.2438 0 0-31e-4 0-31e-4-31e-4-0.2281-0.2062-0.3031-0.3344-0.3281-0.3906 0.1312-0.1 0.6437-0.2875 1.9062-0.2656 1.0969 0.0187 2.4375 0.2 3.5907 0.4812 0.7593 0.1844 1.4031 0.4063 1.8593 0.6375 0.4406 0.2219 0.5969 0.4 0.6344 0.475V9.3z" />
</svg>
</template>

View File

@@ -1,9 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.9031 11.5719C23.7375 11.2219 23.3844 11 23 11h-2c-0.55 0-1-0.45-1-1V9c0-2.1344-0.8312-4.1407-2.3375-5.6531C16.1563 1.8375 14.15 1.0031 12.0125 1h-0.0281C9.85 1.0031 7.8438 1.8375 6.3375 3.3469 4.8313 4.8593 4 6.8656 4 9v1c0 0.55-0.45 1-1 1H1c-0.3875 0-0.7375 0.2219-0.9031 0.5719s-0.1156 0.7625 0.1313 1.0625C2.7032 15.6562 3.9375 18.725 4 22.0187 4.0094 22.5625 4.4563 23 5 23c1.3282 0 2.0344-0.8063 2.5032-1.3406C7.9375 21.1594 8.1125 21 8.5 21s0.5625 0.1594 0.9969 0.6594C9.9656 22.1937 10.6719 23 12 23c1.3282 0 2.0344-0.8063 2.5031-1.3406C14.9375 21.1594 15.1125 21 15.5 21s0.5625 0.1594 0.9969 0.6594C16.9656 22.1937 17.6719 23 19 23c0.5438 0 0.9906-0.4375 1-0.9813 0.0625-3.2937 1.2969-6.3656 3.775-9.3843 0.2438-0.3 0.2938-0.7125 0.1281-1.0625zM2.9969 13H3c0.7688 0 1.4688-0.2906 2-0.7656v4.5781c-0.0563-0.1469-0.1125-0.2906-0.1719-0.4375C4.3625 15.2375 3.7469 14.1094 2.9969 13zM15.5 19c-1.3281 0-2.0344 0.8063-2.5031 1.3406C12.5625 20.8406 12.3875 21 12 21s-0.5625-0.1594-0.9969-0.6594C10.5344 19.8063 9.8281 19 8.5 19S6.4656 19.8063 6 20.3375V10.05c0-0.0156 0-0.0344 0-0.05V9c0-3.3031 2.6875-5.9937 5.9875-6h0.0219C15.3125 3.0063 18 5.6969 18 9v11.3375C17.5313 19.8031 16.825 19 15.5 19zM19.1719 16.3781C19.1125 16.525 19.0563 16.6688 19 16.8156v-4.5812C19.5313 12.7094 20.2313 13 21 13h31e-4c-0.75 1.1094-1.3656 2.2375-1.8312 3.3781z" />
<path d="M12 12c-0.5531 0-1.0875 0.2875-1.4594 0.7844C10.1906 13.25 10 13.8594 10 14.5s0.1906 1.25 0.5406 1.7156C10.9156 16.7156 11.4469 17 12 17s1.0875-0.2875 1.4594-0.7844C13.8094 15.75 14 15.1406 14 14.5s-0.1906-1.25-0.5406-1.7156C13.0875 12.2875 12.5531 12 12 12zM12 16c-0.5406 0-1-0.6875-1-1.5s0.4594-1.5 1-1.5 1 0.6875 1 1.5-0.4562 1.5-1 1.5z" />
<path d="M11 8.5C11 7.6719 10.3281 7 9.5 7S8 7.6719 8 8.5 8.6719 10 9.5 10 11 9.3281 11 8.5zM9.5 9C9.225 9 9 8.775 9 8.5S9.225 8 9.5 8 10 8.225 10 8.5 9.775 9 9.5 9z" />
<path d="M14.5 7C13.6719 7 13 7.6719 13 8.5s0.6719 1.5 1.5 1.5S16 9.3281 16 8.5 15.3281 7 14.5 7zM14.5 9C14.225 9 14 8.775 14 8.5S14.225 8 14.5 8 15 8.225 15 8.5 14.775 9 14.5 9z" />
</svg>
</template>

View File

@@ -1,9 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22.2438 7.1125c-0.5782-0.9469-1.2625-1.6969-2.0313-2.2281C19.3625 4.2969 18.45 4 17.5 4c-0.7437 0-1.4656 0.1844-2.1531 0.5438-0.7938-1.7375-1.9719-2.8532-2.8438-3.4907-0.9937-0.7312-1.8093-1.0156-1.8406-1.025-0.1812-0.0625-0.3812-0.0156-0.5156 0.1188l-1 1C9.05 1.2438 8.9969 1.3782 9 1.5157 9.0031 1.6531 9.0656 1.7844 9.1688 1.875c0.2531 0.2281 0.9468 1.0031 0.7874 1.5688-0.1125 0.4-0.6687 0.7375-1.5843 0.9656C7.7688 4.1375 7.1437 4.0031 6.5 4.0031c-0.95 0-1.8625 0.2969-2.7094 0.8844-0.7719 0.5344-1.4531 1.2844-2.0312 2.2281C0.625 8.9688 0 11.4157 0 14c0 2.5844 0.625 5.0313 1.7562 6.8844 0.5782 0.9469 1.2625 1.6969 2.0313 2.2281 0.8468 0.5875 1.7594 0.8844 2.7094 0.8844 0.7562 0 1.4906-0.1875 2.1843-0.5625 1.0594 0.3719 2.1719 0.5625 3.3157 0.5625 1.1406 0 2.2562-0.1875 3.3156-0.5625 0.6969 0.3719 1.4281 0.5625 2.1844 0.5625 0.95 0 1.8625-0.2969 2.7093-0.8844 0.7719-0.5344 1.4532-1.2844 2.0313-2.2281C23.375 19.0313 24 16.5844 24 14c0-2.5843-0.625-5.0312-1.7562-6.8875zM10.6156 1.0906c0.2906 0.1344 0.7781 0.3844 1.325 0.7906 0.8656 0.6407 1.5688 1.4219 2.1031 2.3282C13.375 4.0718 12.6937 4 11.9969 4c-0.4157 0-0.825 0.025-1.2313 0.075 0.0656-0.1156 0.1156-0.2406 0.1531-0.3688 0.2438-0.8812-0.3125-1.725-0.7187-2.2zM17.5 22c-0.3344 0-0.6687-0.0687-0.9969-0.2094 0.4563-0.2625 0.8907-0.5656 1.2969-0.9093l0.3813-0.3219-0.6438-0.7656-0.3812 0.325c-1.4282 1.2031-3.2407 1.8719-5.1094 1.8812 0 0 0 0 0 0-0.0157 0-0.0282 0-0.0438 0s-0.0281 0-0.0437 0c0 0 0 0 0 0-1.8688-93e-4-3.6844-0.6781-5.1125-1.8812l-0.3813-0.3219-0.6437 0.7656 0.3812 0.3219c0.4063 0.3438 0.8406 0.6469 1.2969 0.9094-0.3281 0.1375-0.6594 0.2093-0.9969 0.2093-2.4406 0-4.5-3.6625-4.5-7.9999 0-4.3375 2.0594-8 4.5-8 0.3344 0 0.6688 0.0687 0.9969 0.2093C7.0437 6.475 6.6094 6.7781 6.2031 7.1219L5.8219 7.4438 6.4656 8.2094 6.8469 7.8875c1.4312-1.2031 3.2437-1.8718 5.1125-1.8812 0 0 0 0 0 0 0.0156 0 0.0281 0 0.0437 0s0.0281 0 0.0438 0c0 0 0 0 0 0 1.8687 94e-4 3.6844 0.6781 5.1125 1.8812l0.3812 0.3219 0.6438-0.7656-0.3813-0.3219c-0.4062-0.3438-0.8406-0.6469-1.2968-0.9094 0.3281-0.1375 0.6593-0.2093 0.9968-0.2093 2.4406 0 4.5 3.6625 4.5 8C22 18.3375 19.9406 22 17.5 22z" />
<path d="M9.9406 12.7344c0.0875-0.1625 0.0781-0.3594-0.025-0.5125l-2-3C7.8219 9.0812 7.6656 9 7.5 9S7.1781 9.0844 7.0844 9.2219l-2 3c-0.1031 0.1531-0.1125 0.35-0.025 0.5125S5.3156 13 5.5 13h4c0.1844 0 0.3531-0.1 0.4406-0.2656zM6.4344 12 7.5 10.4 8.5656 12z" />
<path d="M16.9094 9.2219C16.8156 9.0813 16.6594 9 16.4938 9s-0.3219 0.0844-0.4157 0.2219l-1.9999 3c-0.1032 0.1531-0.1125 0.35-0.0251 0.5125C14.1406 12.8969 14.3094 13 14.4938 13h4c0.1844 0 0.3531-0.1 0.4406-0.2656s0.0781-0.3594-0.025-0.5125zM15.4281 12l1.0657-1.6 1.0656 1.6z" />
<path d="M17.5 15h-2c-0.1312 0-0.2594 0.0531-0.3531 0.1469l-0.6531 0.6469-0.6407-0.6469C13.7594 15.0531 13.6313 15 13.5 15h-3c-0.1312 0-0.2594 0.0531-0.3531 0.1469L9.5 15.7938 8.8531 15.1469C8.7594 15.0531 8.6312 15 8.5 15h-2C6.225 15 6 15.225 6 15.5c0 0.0562 94e-4 0.1094 0.025 0.1563 0.0531 1.1718 0.6906 2.2593 1.8063 3.075C8.95 19.55 10.4313 20 12 20c1.5688 0 3.05-0.45 4.1688-1.2687 1.1156-0.8157 1.7531-1.9032 1.8062-3.075C17.9907 15.6063 18 15.5531 18 15.5c0-0.275-0.225-0.5-0.5-0.5zM15.5781 17.925C14.6281 18.6188 13.3594 19 12 19s-2.6281-0.3812-3.5781-1.075c-0.7344-0.5375-1.2-1.2063-1.35-1.925h1.2187l0.8531 0.8531c0.1938 0.1938 0.5125 0.1938 0.7063 0L10.7031 16h2.5844l0.8469 0.8531C14.2281 16.9469 14.3562 17 14.4875 17v0c0.1312 0 0.2594-0.0531 0.3531-0.1469L15.7 16h1.2219c-0.1438 0.7188-0.6094 1.3875-1.3438 1.925z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M12 8c-2.2062 0-4 1.7937-4 4s1.7937 4 4 4c2.2062 0 4-1.7937 4-4s-1.7937-4-4-4zM12 14c-1.1031 0-2-0.8969-2-2s0.8969-2 2-2 2 0.8969 2 2-0.8969 2-2 2z" />
<path d="M24 13v-2h-3.0531C20.7594 9.3031 20.1 7.7125 19.0344 6.3813l2.1593-2.1594-1.4156-1.4156-2.1594 2.1625C16.2875 3.9 14.6969 3.2407 13 3.0563V0h-2v3.0532C9.3031 3.2407 7.7125 3.9 6.3812 4.9657L4.2219 2.8063 2.8062 4.2219 4.9656 6.3813C3.8969 7.7125 3.2375 9.3032 3.0531 11H0v2h3.0531c0.1875 1.6969 0.8469 3.2875 1.9125 4.6188l-2.1594 2.1594 1.4157 1.4156 2.1593-2.1594C7.7125 20.1032 9.3031 20.7625 11 20.9469V24h2v-3.0531c1.6969-0.1875 3.2875-0.8469 4.6187-1.9125l2.1594 2.1594 1.4156-1.4156-2.1625-2.1594C20.1 16.2875 20.7593 14.6969 20.9437 13zM19.9375 11h-2.0219c-0.1531-0.9094-0.5125-1.7531-1.0281-2.475l1.4281-1.4281C19.1781 8.2031 19.7562 9.5406 19.9375 11zM12 17c-2.7563 0-5-2.2438-5-5s2.2438-5 5-5c2.7563 0 5 2.2438 5 5s-2.2438 5-5 5zM16.9031 5.6813 15.475 7.1094C14.7531 6.5937 13.9094 6.2344 13 6.0812V4.0625c1.4594 0.1812 2.7969 0.7594 3.9031 1.6188zM11 4.0625v2.0219c-0.9094 0.1531-1.7531 0.5125-2.475 1.0281L7.0969 5.6844C8.2031 4.8219 9.5406 4.2438 11 4.0625zM5.6813 7.0969 7.1094 8.525C6.5937 9.2469 6.2344 10.0906 6.0812 11H4.0625c0.1812-1.4594 0.7594-2.7969 1.6188-3.9031zM4.0625 13h2.0219c0.1531 0.9094 0.5125 1.7531 1.0281 2.475l-1.4281 1.4281C4.8219 15.7969 4.2438 14.4594 4.0625 13zM7.0969 18.3188 8.525 16.8906c0.7219 0.5157 1.5656 0.875 2.475 1.0282v2.0187c-1.4594-0.1812-2.7969-0.7594-3.9031-1.6187zM13 19.9375v-2.0219c0.9094-0.1531 1.7531-0.5125 2.475-1.0281l1.4281 1.4281c-1.1062 0.8625-2.4437 1.4406-3.9031 1.6219zM18.3188 16.9031 16.8906 15.475c0.5157-0.7219 0.875-1.5656 1.0282-2.475h2.0187c-0.1812 1.4594-0.7594 2.7969-1.6187 3.9031z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M18.5 3H2.9156C2.7094 2.4188 2.1531 2 1.5 2 0.6719 2 0 2.6719 0 3.5v14C0 17.775 0.225 18 0.5 18h2C2.775 18 3 17.775 3 17.5V17h6v4.5C9 21.775 9.225 22 9.5 22h5c0.275 0 0.5-0.225 0.5-0.5V17h7c1.1031 0 2-0.8969 2-2V8.5C24 5.4688 21.5312 3 18.5 3zM2 17H1V3.5C1 3.225 1.225 3 1.5 3S2 3.225 2 3.5zM13 21h-2v-4h2zM22 15H3V5h15.5C20.4312 5 22 6.5687 22 8.5z" />
<path d="M19.5 9H10V8.5C10 8.225 9.775 8 9.5 8h-4C5.225 8 5 8.225 5 8.5v4C5 12.775 5.225 13 5.5 13h4c0.275 0 0.5-0.225 0.5-0.5V12h6v1.5c0 0.275 0.225 0.5 0.5 0.5h3c0.275 0 0.5-0.225 0.5-0.5v-4C20 9.225 19.775 9 19.5 9zM9 12H6V9h3zM19 13h-2v-1.5c0-0.275-0.225-0.5-0.5-0.5H10v-1h9z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M18.5 5H9V4h1.5C10.775 4 11 3.775 11 3.5v-3C11 0.225 10.775 0 10.5 0h-4C6.225 0 6 0.225 6 0.5V5H2.9156C2.7094 4.4187 2.1531 4 1.5 4 0.6719 4 0 4.6719 0 5.5v14C0 19.775 0.225 20 0.5 20h2C2.775 20 3 19.775 3 19.5V19h6v4.5C9 23.775 9.225 24 9.5 24h5c0.275 0 0.5-0.225 0.5-0.5V19h7c1.1031 0 2-0.8969 2-2v-6.5C24 7.4688 21.5312 5 18.5 5zM2 19H1V5.5C1 5.225 1.225 5 1.5 5S2 5.225 2 5.5zM9 11v3H6v-3zM10 1v2H8.5C8.225 3 8 3.225 8 3.5V10H7V1zM13 23h-2v-4h2zM22 17H3V7h3v3H5.5C5.225 10 5 10.225 5 10.5v4C5 14.775 5.225 15 5.5 15h4c0.275 0 0.5-0.225 0.5-0.5v-4c0-0.275-0.225-0.5-0.5-0.5H9V7h9.5c1.9313 0 3.5 1.5687 3.5 3.5z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M19.5 6.7875l-0.625-0.625 0.2313-0.8594C19.35 4.3875 18.8031 3.4437 17.8906 3.2l-0.8531-0.2281-0.2313-0.8594C16.5594 1.1969 15.6125 0.6531 14.7 0.8969L13.8469 1.125 13.2187 0.4969c-0.6719-0.6688-1.7625-0.6657-2.4312 0l-0.625 0.625-0.8594-0.2313C8.3875 0.6469 7.4437 1.1937 7.2 2.1062L6.9719 2.9594 6.1125 3.1906C5.1968 3.4375 4.6531 4.3844 4.8968 5.2969L5.125 6.15 4.4968 6.7812c-0.6687 0.6719-0.6656 1.7625 0 2.4313l0.625 0.625-0.2312 0.8594c-0.2438 0.9156 0.3031 1.8593 1.2156 2.1062l1.8907 0.5063V23c0 0.4031 0.2437 0.7688 0.6187 0.925 0.375 0.1563 0.8031 0.0688 1.0906-0.2156L12 21.4156l2.2937 2.2938C14.4844 23.9 14.7406 24.0031 15 24.0031c0.1281 0 0.2594-0.025 0.3812-0.075C15.7562 23.7719 16 23.4094 16 23.0031v-9.6906l1.8875-0.5063c0.9156-0.2468 1.4594-1.1937 1.2156-2.1062L18.875 9.8469l0.6281-0.6282c0.6656-0.6718 0.6656-1.7625-31e-4-2.4312zM12 11c-1.6531 0-3-1.3469-3-3s1.3469-3 3-3 3 1.3469 3 3-1.3469 3-3 3zM12.7062 19.2938c-0.3906-0.3907-1.0249-0.3907-1.4156 0L10 20.5844v-8.0031C10.6125 12.85 11.2906 13 12 13c0.7093 0 1.3875-0.15 2-0.4187v8.0031zM18.7938 8.5125 17.9625 9.3438c-0.125 0.125-0.175 0.3093-0.1281 0.4843l0.3031 1.1313c0.1031 0.3813-0.1281 0.7781-0.5094 0.8812L16 12.2782V11c0.6281-0.8343 1-1.875 1-2.9968 0-2.7563-2.2437-5-5-5-2.7562 0-5 2.2437-5 5C7 9.125 7.3719 10.1657 8 11v1.275l-1.6312-0.4375c-0.3813-0.1031-0.6094-0.5-0.5094-0.8812l0.3031-1.1344C6.2094 9.65 6.1594 9.4657 6.0344 9.3375L5.2063 8.5094c-0.2813-0.2812-0.2782-0.7375 0-1.0188l0.8312-0.8312c0.125-0.125 0.175-0.3094 0.1282-0.4844L5.8625 5.0437C5.7594 4.6625 5.9907 4.2656 6.3719 4.1625l1.1344-0.3031c0.1718-0.0469 0.3062-0.1813 0.3531-0.3532L8.1625 2.375c0.1032-0.3813 0.5-0.6094 0.8813-0.5094l1.1344 0.3031c0.1718 0.0469 0.3562-31e-4 0.4843-0.1281l0.8282-0.8281c0.2812-0.2813 0.7375-0.2781 1.0187 0l0.8344 0.825c0.125 0.125 0.3094 0.175 0.4844 0.1281l1.1312-0.3031c0.3813-0.1031 0.7782 0.1281 0.8813 0.5094l0.3031 1.1343c0.0469 0.1719 0.1812 0.3063 0.3531 0.3532l1.1313 0.3031c0.3812 0.1031 0.6094 0.5 0.5094 0.8812l-0.3032 1.1344c-0.0468 0.1719 32e-4 0.3563 0.1282 0.4844l0.8281 0.8281c0.2812 0.2844 0.2812 0.7406 31e-4 1.0219z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.6562 1.2438c-0.2968-0.2563-0.7125-0.3188-1.0687-0.1563l-22 10c-0.3844 0.175-0.6156 0.5688-0.5844 0.9875 0.0313 0.4187 0.3188 0.7719 0.7219 0.8875L7 14.7531V22c0 0.4406 0.2875 0.8281 0.7094 0.9563C7.8062 22.9844 7.9031 23 8 23c0.3281 0 0.6437-0.1625 0.8312-0.4469l3.5126-5.2625 5.2093 2.6063c0.2657 0.1312 0.575 0.1406 0.8469 0.0219 0.2719-0.1188 0.4781-0.35 0.5594-0.6344l5-17C24.0688 1.9063 23.95 1.5 23.6562 1.2438zM3.8875 11.7844l14.0781-6.4-9.6031 7.6844c-0.0281-0.0125-0.0593-0.0219-0.0875-0.0313zM9 18.6969V14c0-0.05-31e-4-0.1-0.0125-0.15l10.4719-8.3781-8.2281 9.8875c-0.0219 0.0281-0.0438 0.0562-0.0626 0.0843zM17.3781 17.5719l-4.7218-2.3625 8.3749-10.0657z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.85 4.7438c-0.3531-0.6532-1.2219-0.8782-2.5812-0.6688-0.9375 0.1438-2.1282 0.4969-3.4813 1.0281C16.1656 3.7438 14.1406 3 12 3 9.5969 3 7.3344 3.9375 5.6375 5.6375S3 9.5969 3 12c0 0.7188 0.0844 1.4219 0.2469 2.1031-1.0438 0.9438-1.8688 1.825-2.4156 2.5813-0.7969 1.1031-1.0188 1.9437-0.6813 2.5718 0.1188 0.2219 0.375 0.5157 0.9031 0.6563C1.2781 19.9719 1.5375 20 1.8187 20c1.2594 0 2.9719-0.55 4.3001-1.0625 0.0343-0.0125 0.0656-0.025 0.1-0.0375C7.8344 20.2563 9.8594 21 12 21c2.4031 0 4.6657-0.9375 6.3625-2.6375C20.0625 16.6656 21 14.4031 21 12c0-0.7188-0.0844-1.4219-0.2468-2.1031 0.2343-0.2125 0.4593-0.4219 0.675-0.6313 0.9468-0.9156 1.6437-1.7406 2.0687-2.4437 0.5188-0.8531 0.6344-1.5532 0.3531-2.0781zM12 5c3.2625 0 6.0094 2.2437 6.7844 5.2687-1.6031 1.3094-3.5281 2.6657-5.6125 3.9469-2.1031 1.2938-4.0469 2.3156-5.7438 3.0813C5.9406 16.0125 5 14.1125 5 12c0-3.8594 3.1406-7 7-7zM1.3125 18.9438c-0.1031-0.0282-0.2344-0.0782-0.2812-0.1657-0.0938-0.175 93e-4-0.675 0.6093-1.5062 0.4469-0.6157 1.1032-1.3344 1.9282-2.1063 0.4093 1.0875 1.0281 2.0938 1.8406 2.9656-1.9688 0.7313-3.4125 0.9969-4.0969 0.8126zM19 12c0 3.8594-3.1406 7-7 7-1.3344 0-2.5844-0.375-3.6469-1.0281 1.6938-0.7938 3.5063-1.7782 5.3407-2.9063 1.9312-1.1875 3.7343-2.4437 5.2812-3.6687 0.0156 0.2 0.025 0.4 0.025 0.6031zM20.7312 8.5469c-0.1 0.0968-0.1999 0.1906-0.3031 0.2875-0.4094-1.0906-1.0281-2.0938-1.8437-2.9656 1.1-0.4094 2.0656-0.6844 2.8312-0.8032 1-0.1531 1.4594-0.0125 1.55 0.1563 0.1469 0.2625-0.1375 1.2969-2.2344 3.325z" />
</svg>
</template>

View File

@@ -1,9 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22 9h-5V4c0-1.1031-0.8969-2-2-2H9C7.8969 2 7 2.8969 7 4v2H2C0.8969 6 0 6.8969 0 8v13c0 0.5531 0.4469 1 1.0001 1h22c0.5531 0 1-0.4469 1-1V11c0-1.1031-0.8969-2-2.0001-2zM15 4v16H9V4zM2 8h5v12H2zM22 20h-5v-9h5z" />
<path d="M4.7031 12.6187C5.25 12.2062 6 11.6437 6 10.5c0-0.45-0.1719-0.85-0.4812-1.125C5.2469 9.1313 4.8844 8.9969 4.5 8.9969S3.7531 9.1313 3.4812 9.375C3.1719 9.65 3 10.05 3 10.5V11h1v-0.5C4 10.0219 4.4156 10 4.5 10c0.1375 0 0.2656 0.0438 0.3562 0.125C4.9531 10.2094 5 10.3375 5 10.5031c0 0.6125-0.3406 0.9-0.8969 1.3188C3.6125 12.1875 3 12.6438 3 13.5 3 13.775 3.225 14 3.5 14H6v-1H4.2344c0.1156-0.1156 0.2718-0.2344 0.4687-0.3813z" />
<path d="M19.5 16H18v1h1.5c0.8281 0 1.5-0.6719 1.5-1.5 0-0.6719-0.4437-1.2406-1.05-1.4312L20.9 12.8c0.1125-0.15 0.1312-0.3531 0.0469-0.525C20.8625 12.1063 20.6875 12 20.5 12H18v1h1.5l-0.9 1.2c-0.1125 0.15-0.1313 0.3532-0.0469 0.525C18.6375 14.8938 18.8125 15 19 15h0.5c0.275 0 0.5 0.225 0.5 0.5S19.775 16 19.5 16z" />
<path d="M12 6.7062V10h1V5.5c0-0.2031-0.1219-0.3844-0.3094-0.4625s-0.4031-0.0344-0.5437 0.1094L10.7938 6.5 11.5 7.2062z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22.9719 2.8125c-1.7157-0.9469-4.025-1.0844-5.7469-0.3375-0.5094 0.2219-0.9063 0.6469-1.0938 1.1719-0.1843 0.5219-0.1437 1.1 0.1125 1.5843 0.1282 0.2438 0.1938 0.5 0.1938 0.7657 0 0.6-0.1875 2-1.9375 2-1.7219 0-1.9063-1.3969-1.9063-2 0-0.2656 0.0657-0.5219 0.1938-0.7657 0.2562-0.4875 0.2969-1.0624 0.1125-1.5843-0.1844-0.525-0.5844-0.9532-1.0938-1.1719-1.5468-0.6687-4.0812-0.6281-5.6437 0.0937C5.675 2.7937 5.3062 3.1969 5.125 3.7031 4.9437 4.2094 4.9687 4.7531 5.2 5.2375c0.6937 1.45 0.8406 2.3875 0.4906 3.1344 0 0 0 0 0 0C5.0125 8.0531 4.2531 7.9344 3.5 8.0281c-0.8094 0.1-1.5844 0.4282-2.3032 0.975C1.125 9.0563 1.0625 9.1219 1.0062 9.1937c-1.0844 1.425-1.3062 3.1344-0.5937 4.575 0.325 0.6594 0.825 1.2125 1.4468 1.6063 0.6407 0.4062 1.3782 0.6187 2.1375 0.6187 0.5719 0 1.125-0.1187 1.6438-0.3531 31e-4 0 62e-4-31e-4 94e-4-31e-4 0.3875 0.8531 0.2593 1.8187-0.4188 3.1312-0.25 0.4844-0.2937 1.0375-0.125 1.5532 0.1688 0.5093 0.525 0.9218 1.0031 1.1593 0.8344 0.4157 1.8532 0.5094 2.6157 0.5094 0.0625 0 0.125 0 0.1875-31e-4 1.0125-0.0219 2.0375-0.2125 2.8187-0.5281 0.5094-0.2063 0.9157-0.6188 1.1125-1.1344 0.2-0.5219 0.1719-1.1094-0.0781-1.6125-0.1156-0.2313-0.1719-0.475-0.1719-0.725 0-0.6 0.1844-2 1.9063-2 1.75 0 1.9375 1.3969 1.9375 2 0 0.2656-0.0656 0.5219-0.1938 0.7656-0.2562 0.4875-0.2968 1.0625-0.1125 1.5844 0.1844 0.525 0.5844 0.9531 1.0938 1.1719 0.7406 0.3219 1.5906 0.4781 2.4562 0.4781 1.1438 0 2.3157-0.275 3.2907-0.8156C23.6063 20.8219 24 20.1531 24 19.425V4.5625c0-0.7281-0.3937-1.4-1.0281-1.75zM22 4.5625v14.875c-1.1625 0.6406-2.8375 0.7438-3.9812 0.25 0.2781-0.5312 0.4187-1.1 0.4187-1.6875 0-1.0875-0.3562-2.0812-1.0031-2.7937C16.7188 14.4157 15.7031 14 14.5 14s-2.2156 0.4188-2.925 1.2125c-0.6437 0.7188-0.9812 1.6844-0.9812 2.7875 0 0.5594 0.1281 1.1031 0.3781 1.6125 31e-4 31e-4 31e-4 94e-4 31e-4 94e-4-1.1719 0.4687-3.0938 0.5093-3.9688 0.0812 0-31e-4 0-62e-4 32e-4-93e-4 0.4437-0.8594 0.7093-1.6344 0.8093-2.3688 0.1219-0.8844 63e-4-1.7281-0.3468-2.5062-0.4563-1.0032-1.6438-1.45-2.6532-0.9938C4.5593 13.9406 4.2844 14 3.9968 14c-0.7656 0-1.4531-0.4281-1.7937-1.1125-0.3594-0.7219-0.2437-1.5781 0.3125-2.3688 1.0469-0.7406 1.9031-0.5281 2.3281-0.3312 0.275 0.1281 0.5625 0.1875 0.8469 0.1875 0.7437 0 1.4594-0.4156 1.8-1.125 0.3593-0.7531 0.4687-1.5937 0.3218-2.5031C7.6999 6.0531 7.4468 5.3 7.0093 4.3844c1.0406-0.4781 2.9844-0.5125 4.0031-0.0719-0.2781 0.5313-0.4187 1.1-0.4187 1.6875 0 1.1031 0.3406 2.0688 0.9812 2.7875C12.2843 9.5812 13.2937 10 14.4999 10c1.2032 0 2.2188-0.4156 2.9344-1.2063C18.0812 8.0812 18.4374 7.0875 18.4374 6c0-0.5906-0.1406-1.1594-0.4187-1.6875 1.1468-0.4969 2.8187-0.3907 3.9812 0.25z" />
</svg>
</template>

View File

@@ -1,11 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M9 3C6.5969 3 4.3344 3.9375 2.6375 5.6375v0C0.9375 7.3344 0 9.5969 0 12s0.9375 4.6656 2.6375 6.3625C4.3344 20.0625 6.5969 21 9 21s4.6656-0.9375 6.3625-2.6375C17.0625 16.6656 18 14.4031 18 12c0-1.7031-0.4719-3.3375-1.35-4.7469 2.3031 1.1938 2.5812 3.4906 2.8531 5.7282 0.1531 1.2656 0.3 2.4625 0.8188 3.3875C20.9375 17.4656 21.975 18 23.5 18H24v-1h-0.5c-1.4875 0-2.0125-0.5969-2.3031-1.1187-0.4219-0.7532-0.5563-1.8563-0.6969-3.0219-0.1688-1.3906-0.3625-2.9656-1.1-4.2938-0.7906-1.4218-2.0719-2.3406-3.9094-2.8-0.0406-0.0437-0.0844-0.0843-0.125-0.1281C13.6656 3.9375 11.4031 3 9 3zM13.95 16.95c-2.7281 2.7281-7.1688 2.7281-9.9 0-1.3656-1.3656-2.0469-3.1563-2.0469-4.95S2.6844 8.4156 4.05 7.05c2.7281-2.7281 7.1688-2.7281 9.9 0 2.7281 2.7281 2.7281 7.1719 0 9.9z" />
<path d="M8 12c0 0.55 0.45 1 1 1s1-0.45 1-1-0.45-1-1-1-1 0.45-1 1zM9 12c0 0 0 0 0 0z" />
<path d="M6.7625 13.4719c0.0406-63e-4 0.0844-0.0157 0.1219-0.0344 0.1937-0.0781 0.3125-0.2656 0.3125-0.4625 0-0.0625-0.0125-0.125-0.0344-0.1844l-63e-4-0.0156c-31e-4-63e-4-31e-4-94e-4-62e-4-0.0156-0.1-0.2438-0.15-0.5-0.15-0.7563 0-0.2625 0.05-0.5219 0.1531-0.7656 0.05-0.1219 0.05-0.2594 0-0.3844-0.05-0.1219-0.1469-0.2188-0.2719-0.2719L4.1093 9.4343c-0.25-0.1031-0.5344 94e-4-0.6469 0.2563L3.4468 9.725c-31e-4 62e-4-62e-4 0.0125-62e-4 0.0187-0.5907 1.4438-0.5844 3.1031 0.0156 4.5532 0.1062 0.2562 0.3969 0.375 0.6531 0.2718zM4.2125 10.5625l1.8656 0.7719c-0.1 0.4375-0.1 0.8969 0 1.3375l-1.8625 0.7718c-0.2844-0.9375-0.2844-1.95-31e-4-2.8812z" />
<path d="M10.4375 14.1156c-0.1031-0.2531-0.3938-0.3781-0.6469-0.275l-0.0156 63e-4c-63e-4 31e-4-94e-4 31e-4-0.0156 63e-4-0.2438 0.1-0.5 0.15-0.7563 0.15-0.2625 0-0.5219-0.05-0.7656-0.1532-0.1219-0.05-0.2594-0.05-0.3844 0s-0.2188 0.1469-0.2719 0.2719l-1.1437 2.7688c-0.1032 0.25 93e-4 0.5343 0.2562 0.6468l0.0344 0.0157c62e-4 31e-4 0.0125 62e-4 0.0187 62e-4C8.1906 18.15 9.85 18.1438 11.3 17.5438c0.1937-0.0813 0.3093-0.2656 0.3093-0.4625 0-0.0625-0.0124-0.1281-0.0375-0.1906l-1.0968-2.6531c-94e-4-0.0407-0.0219-0.0813-0.0375-0.122zM10.4438 16.7875c-0.9375 0.2813-1.9469 0.2813-2.8813 31e-4l0.7719-1.8656C8.5531 14.975 8.7781 15 9.0031 15s0.45-0.025 0.6688-0.075z" />
<path d="M11.2375 10.5281c-0.0406 63e-4-0.0844 0.0157-0.1219 0.0344-0.2531 0.1031-0.3781 0.3938-0.275 0.6469l63e-4 0.0156c31e-4 63e-4 31e-4 94e-4 63e-4 0.0156 0.1 0.2438 0.15 0.5 0.15 0.7563 0 0.2625-0.05 0.5219-0.1532 0.7656-0.05 0.1219-0.05 0.2594 0 0.3844 0.05 0.1219 0.1469 0.2188 0.2719 0.2719l2.7688 1.1437c0.25 0.1032 0.5343-93e-4 0.6468-0.2562l0.0157-0.0344c31e-4-62e-4 62e-4-0.0125 62e-4-0.0187 0.2938-0.7157 0.4375-1.4844 0.4375-2.2532 0-0.7843-0.1531-1.5687-0.4562-2.2999-0.1063-0.2563-0.3969-0.375-0.6532-0.2719zM13.7875 13.4375l-1.8656-0.7719c0.1-0.4375 0.1-0.8969 0-1.3375l1.8625-0.7719c0.2844 0.9375 0.2844 1.9501 31e-4 2.8813z" />
<path d="M11.3062 6.4625 11.2719 6.4469c-63e-4-32e-4-0.0125-63e-4-0.0188-63e-4C9.8094 5.85 8.15 5.8562 6.7 6.4562 6.4437 6.5625 6.325 6.8531 6.4281 7.1094L7.525 9.7625c62e-4 0.0406 0.0156 0.0844 0.0344 0.125 0.1031 0.2531 0.3937 0.3781 0.65 0.275l0.0156-63e-4c63e-4-31e-4 94e-4-31e-4 0.0156-62e-4 0.2438-0.1 0.5-0.15 0.7563-0.15 0.2625 0 0.5219 0.05 0.7656 0.1531 0.1219 0.05 0.2594 0.05 0.3844 0 0.1219-0.05 0.2187-0.1469 0.2719-0.2719l1.1468-2.7719c0.025-0.0625 0.0375-0.1281 0.0375-0.1906-31e-4-0.1906-0.1125-0.3719-0.2969-0.4562zM10.4375 7.2125 9.6656 9.0781c-0.2187-0.05-0.4437-0.075-0.6687-0.075s-0.45 0.025-0.6688 0.075L7.5563 7.2156c0.9375-0.2844 1.95-0.2844 2.8812-31e-4z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.9562 6.4563 22.5406 5.0406l-6.25 6.25c-0.3906 0.3907-0.3906 1.025 0 1.4157l6.25 6.25 1.4156-1.4157L18.4156 12z" />
<path d="M1.4562 5.0437 0.0437 6.4563 5.5844 12 0.0437 17.5437l1.4157 1.4157 6.25-6.25c0.3906-0.3907 0.3906-1.025 0-1.4157z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M11.2938 16.2938l-6.25 6.25 1.4156 1.4156L12 18.4156l5.5437 5.5438 1.4157-1.4156-6.25-6.25c-0.3938-0.3907-1.025-0.3907-1.4156 0z" />
<path d="M12 8c0.2562 0 0.5125-0.0969 0.7062-0.2937l6.25-6.2501-1.4125-1.4125L12 5.5844 6.4563 0.0437 5.0406 1.4594l6.25 6.25C11.4875 7.9031 11.7438 8 12 8z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.8531 13.1469 22.5 11.7938 21.7938 12.5l0.5 0.5h-9.6563c-0.5187-1.5813-1.0563-3.1969-1.7375-4.4469C10.4844 7.7875 10.0438 7.2156 9.5594 6.8 8.9406 6.2687 8.2469 6 7.4969 6S6.0531 6.2688 5.4344 6.8C4.95 7.2156 4.5125 7.7875 4.0938 8.5531c-0.4094 0.75-0.7657 1.6313-1.1 2.5594V2.7062l0.5 0.5L4.2063 2.5 2.8531 1.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L0.7938 2.5 1.5 3.2062l0.5-0.5V23h1v-9h7.8625c0.5188 1.5812 1.0563 3.1969 1.7375 4.4469 0.4156 0.7656 0.8563 1.3375 1.3406 1.7531 0.6188 0.5312 1.3125 0.8 2.0625 0.8s1.4438-0.2688 2.0625-0.8c0.4844-0.4156 0.9219-0.9875 1.3407-1.7531 0.6812-1.25 1.2187-2.8657 1.7375-4.4469h1.1531l-0.5 0.5 0.7062 0.7062 1.3532-1.3531c0.1937-0.1937 0.1937-0.5125-32e-4-0.7062zM5.8531 9.5094C6.6781 8 7.2969 8 7.5 8s0.8219 0 1.6469 1.5094C9.6594 10.45 10.1063 11.7125 10.5344 13H4.4656c0.4282-1.2875 0.875-2.55 1.3875-3.4906zM17.6469 17.4906C16.8219 19 16.2031 19 16 19s-0.8219 0-1.6469-1.5094C13.8406 16.55 13.3937 15.2875 12.9656 14h6.0656c-0.425 1.2875-0.8718 2.55-1.3843 3.4906z" />
</svg>
</template>

View File

@@ -1,11 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M12 13c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 14c0 0 0 0 0 0z" />
<path d="M12 16c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 17c0 0 0 0 0 0z" />
<path d="M12 19c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 20c0 0 0 0 0 0z" />
<path d="M11 5.5C11 5.7761 10.7761 6 10.5 6S10 5.7761 10 5.5 10.2239 5 10.5 5 11 5.2239 11 5.5z" />
<path d="M14 5.5C14 5.7761 13.7761 6 13.5 6S13 5.7761 13 5.5 13.2239 5 13.5 5 14 5.2239 14 5.5z" />
<path d="M24 10V9h-2.2938l1.5-1.5L22.5 6.7938l-1.5 1.5V6h-1v3.2937l-2.2 2.2001c-0.0313-0.0344-0.0625-0.0657-0.0969-0.1001 0.1844-0.25 0.2938-0.5593 0.2938-0.8906 0-0.4406-0.1907-0.8375-0.4938-1.1125 0.325-0.75 0.4938-1.5625 0.4938-2.3875 0-1.4906-0.5532-2.9125-1.5282-4H19V2h-2c0-1.1032-0.8969-2-2-2H9C7.8969 0 7 0.8969 7 2H5v1h2.5281C6.5563 4.0875 6 5.5094 6 7 6 7.825 6.1688 8.6375 6.4938 9.3875 6.1907 9.6625 6 10.0594 6 10.5c0 0.3344 0.1094 0.6438 0.2938 0.8906-0.0313 0.0344-0.0657 0.0657-0.0969 0.1L4 9.2938V6H3v2.2938l-1.5-1.5L0.7938 7.5l1.5 1.5H0v1h3.2938l2.2625 2.2625C4.5531 13.625 4 15.2813 4 17c0 1.4031 0.3688 2.7844 1.0688 3.9969C5.2719 21.35 5.5031 21.6844 5.7563 22H3v2h18v-2h-2.7562c0.2531-0.3156 0.4843-0.6531 0.6874-1.0031C19.6312 19.7844 20 18.4031 20 17c0-1.7187-0.5531-3.375-1.5531-4.7375L20.7094 10zM7.5 11C7.225 11 7 10.775 7 10.5S7.225 10 7.5 10h9c0.275 0 0.5 0.225 0.5 0.5S16.775 11 16.5 11zM14.3125 3.7344C15.3719 4.4844 16 5.7 16 7c0 0.7031-0.1844 1.3937-0.5344 2v0h-1.2312C14.7094 8.4687 15 7.7687 15 7h-1c0 1.1031-0.8969 2-2 2s-2-0.8969-2-2H9c0 0.7687 0.2906 1.4687 0.7657 2H8.5344C8.1844 8.3969 8 7.7031 8 7 8 5.7031 8.6313 4.4875 9.6875 3.7344 10.3657 3.2531 11.1657 3 12 3c0.8344 0 1.6344 0.2531 2.3125 0.7344zM9 1h6c0.55 0 1 0.45 1 1H8c0-0.55 0.45-1 1-1zM15.9625 12.4938C17.2594 13.6344 18 15.275 18 17c0 2.0344-0.9969 3.8844-2.6812 5H8.6813C6.9969 20.8844 6 19.0344 6 17c0-1.725 0.7406-3.3656 2.0375-4.5062C8.2406 12.3156 8.4563 12.15 8.6812 12h6.6375c0.2251 0.15 0.4407 0.3156 0.6438 0.4938z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.4406 17.9219c-0.1812-0.2375-0.4-0.4563-0.6562-0.6563l-7.2781-6.1562 4.4031-9.6969c0.2031-0.4437 0.0531-0.9687-0.35-1.2437-0.4031-0.2719-0.9469-0.2157-1.2844 0.1375l-6.9375 7.275-8.6906-7.3438c-0.3781-0.3187-0.9313-0.3156-1.3031 94e-4-0.3719 0.325-0.4532 0.8719-0.1875 1.2906l6.3718 10.0469-6.6874 7.0125c-0.1032 0.1031-0.1969 0.2125-0.2844 0.325C0.1938 19.4 0 19.9469 0 20.5c0 0.5532 0.1938 1.1 0.5594 1.5781 0.3094 0.4094 0.7407 0.7657 1.275 1.0625C2.8282 23.6938 4.1313 24 5.5001 24c1.3687 0 2.6718-0.3062 3.6687-0.8594 0.5344-0.2968 0.9625-0.6562 1.275-1.0625 0.1281-0.1687 0.2375-0.3499 0.3219-0.5343v0l1.2843-2.8282 1.3063 2.0625c0.0593 0.1032 0.1281 0.2032 0.2 0.3 0.3093 0.4094 0.7406 0.7657 1.275 1.0625C15.8281 22.6938 17.1313 23 18.5 23c1.3688 0 2.6719-0.3062 3.6688-0.8594 0.5343-0.2969 0.9625-0.6562 1.275-1.0625 0.3656-0.4781 0.5593-1.025 0.5593-1.5781s-0.1968-1.1-0.5625-1.5781zM9.9844 18.4313C9.75 18.2219 9.475 18.0312 9.1688 17.8594 8.1719 17.3063 6.8688 17 5.5 17c-0.1281 0-0.2562 31e-4-0.3812 94e-4l3.5125-3.6844 2.1406 3.375zM11.2656 15.6125 9.3438 12.5813l2.8343-2.975 1.3125 1.1093zM13.9188 9.7688l-1.05-0.8876 2.775-2.9125zM6.3438 5.9812 9.9562 9.0375 8.95 10.0938zM5.5 22C3.3969 22 2 21.0969 2 20.5c0-0.1375 0.075-0.2906 0.2125-0.4469v0l0.0719-0.075C2.7938 19.4844 3.9563 19 5.5 19 7.6031 19 9 19.9031 9 20.5c0 0.0594-0.0125 0.1188-0.0406 0.1813l-0.0156 0.0375C8.6656 21.2937 7.3594 22 5.5 22zM14.6469 13.0031 18.2 16.0062c-1.2563 0.0407-2.4438 0.3407-3.3656 0.8532-0.4282 0.2375-0.7875 0.5125-1.0719 0.8219l-0.7219-1.1375zM18.5 21c-1.7094 0-2.9531-0.5969-3.3594-1.1406l-0.0781-0.1219c-0.0437-0.0813-0.0656-0.1625-0.0656-0.2375 0-0.5969 1.3969-1.5 3.5-1.5s3.5 0.9031 3.5 1.5S20.6031 21 18.5 21z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
<path
d="M767 312c-3.1-12-12.8-21.1-25-23.4l-231.1-44.3-97.5-225c-5.1-11.7-16.6-19.3-29.4-19.3s-24.3 7.6-29.4 19.3l-97.5 225-231.1 44.3c-12.2 2.3-21.9 11.4-25 23.4s1 24.7 10.5 32.6l176.6 147.1-59.1 236.5c-3.1 12.4 1.5 25.5 11.7 33.3 10.2 7.7 24.1 8.6 35.2 2.3l208.1-118.9 208.1 118.9c4.9 2.8 10.4 4.2 15.9 4.2 6.8 0 13.6-2.2 19.3-6.5 10.2-7.7 14.8-20.8 11.7-33.3l-59.1-236.5 176.6-147.1c9.5-7.9 13.6-20.6 10.5-32.6zM523.5 455.4c-9.4 7.9-13.5 20.4-10.6 32.3l45.9 183.3-158.9-90.8c-4.9-2.8-10.4-4.2-15.9-4.2s-11 1.4-15.9 4.2l-158.9 90.8 45.8-183.2c3-11.9-1.1-24.5-10.6-32.3l-140-116.8 181.3-34.8c10.4-2 19.1-9 23.3-18.7l75-172.7 74.9 172.8c4.2 9.7 12.9 16.7 23.3 18.7l181.3 34.8-140 116.6z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.7063 6.7375 17.2625 0.2938C17.075 0.1063 16.8219 0 16.5563 0H7.4438C7.1782 0 6.925 0.1063 6.7375 0.2938L0.2938 6.7375C0.1063 6.925 0 7.1781 0 7.4438v9.1125c0 0.2656 0.1063 0.5187 0.2938 0.7062l6.4437 6.4437C6.925 23.8937 7.1782 24 7.4438 24h9.1125c0.2656 0 0.5187-0.1063 0.7062-0.2938l6.4438-6.4437C23.8938 17.075 24 16.8219 24 16.5563V7.4438c0-0.2657-0.1062-0.5188-0.2937-0.7063zM22 16.1406 16.1406 22H7.8594L2 16.1406V7.8594L7.8594 2h8.2844L22 7.8594z" />
<path d="M16 7.5219c0-0.4094-0.1687-0.8063-0.4625-1.0938C15.2469 6.1437 14.8656 5.9906 14.4656 6c-0.1812 31e-4-0.3562 0.0406-0.5218 0.1031C13.7719 5.4687 13.1906 5 12.5 5s-1.275 0.4687-1.4469 1.1062C10.8813 6.0375 10.6969 6 10.5 6 9.6719 6 9 6.6719 9 7.5v0.5843C8.85 8.0312 8.6938 8.0031 8.5313 8c-0.4-94e-4-0.7813 0.1437-1.0719 0.4281-0.2937 0.2875-0.4625 0.6844-0.4625 1.0938V13c0 1.7687 0.3156 3.2594 0.9094 4.3094C8.525 18.4 9.4438 19 10.4969 19h3.3312c1.2157 0 2.3188-0.6688 2.8813-1.7469l2.1094-4.0437c31e-4-63e-4 62e-4-94e-4 93e-4-0.0157 0.125-0.2406 0.1813-0.5093 0.1657-0.7812-0.0438-0.75-0.6406-1.3563-1.3906-1.4063-0.5094-0.0343-1.0001 0.1875-1.3063 0.5969L16 12zM17.1 12.2c0.1031-0.1375 0.2656-0.2094 0.4375-0.2 0.2438 0.0156 0.4469 0.2219 0.4625 0.4688 63e-4 0.0874-0.0125 0.1718-0.05 0.25-31e-4 31e-4-31e-4 62e-4-63e-4 93e-4l-2.1156 4.0594C15.4375 17.5375 14.675 18 13.8312 18H10.5c-0.8688 0-1.4156-0.6438-1.7219-1.1844C8.2687 15.9156 8 14.5969 8 13V9.5219c0-0.1406 0.0594-0.2782 0.1594-0.3813 0.0968-0.0937 0.2218-0.1469 0.35-0.1437 0.275 62e-4 0.4875 0.225 0.4875 0.5v3.5h1V7.5c0-0.275 0.225-0.5 0.5-0.5s0.5 0.225 0.5 0.5V12h1V6.5c0-0.275 0.225-0.5 0.5-0.5 0.2749 0 0.4999 0.225 0.4999 0.5V12h1.0001V7.5c0-0.275 0.2156-0.4938 0.4875-0.5 0.1312-31e-4 0.2531 0.0469 0.3499 0.1437 0.1032 0.1001 0.1594 0.2375 0.1594 0.3813V13.5c0 0.2156 0.1375 0.4062 0.3406 0.475s0.4282-31e-4 0.5594-0.175z" />
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M19.1406 5H15v2h6c0.5531 0 1-0.4469 1-1V0h-2v3.0593c-0.725-0.6468-1.525-1.2093-2.3844-1.6656C15.9 0.4812 13.9563 0 12 0 10.3813 0 8.8094 0.3187 7.3282 0.9437 5.9 1.5468 4.6157 2.4125 3.5156 3.5156 2.4156 4.6187 1.55 5.8999 0.9438 7.3281 0.3188 8.8093 0 10.3812 0 12h2C2 6.4875 6.4875 1.9999 12 1.9999c2.7031 0 5.275 1.1 7.1406 3.0001z" />
<path d="M4.8594 19H9v-2H3c-0.5531 0-1 0.4469-1 1v6h2v-3.0593c0.725 0.6468 1.525 1.2093 2.3844 1.6656C8.1031 23.5188 10.0437 24 12 24v-1.9999c-2.7031 0-5.275-1.1-7.1406-3.0001z" />
<path d="M3 12h1c0-4.4125 3.5875-8 8-8V3C9.5969 3 7.3375 3.9375 5.6375 5.6375S3 9.5969 3 12z" />
<path d="M18.5 15c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5 1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5zM18.5 17c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
<path d="M23.5812 17.6937 21.9906 16.775C21.9969 16.6844 22 16.5906 22 16.4937c0-0.0937-31e-4-0.1875-94e-4-0.2812l1.5906-0.9188c0.1157-0.0656 0.2-0.175 0.2344-0.3031s0.0157-0.2656-0.05-0.3781l-1-1.7313c-0.1375-0.2374-0.4437-0.3218-0.6812-0.1843l-1.5907 0.9187c-0.0781-0.0531-0.1562-0.1031-0.2406-0.1531s-0.1656-0.0937-0.25-0.1312v-1.8376c0-0.2749-0.225-0.4999-0.5-0.4999h-2c-0.275 0-0.5 0.225-0.5 0.4999v1.8376c-0.0812 0.0406-0.1656 0.0843-0.25 0.1312-0.0813 0.0469-0.1625 0.1-0.2406 0.1531l-1.5907-0.9125c-0.2375-0.1375-0.5437-0.0562-0.6812 0.1844l-1 1.7313c-0.0657 0.1156-0.0844 0.25-0.05 0.3781s0.1187 0.2375 0.2344 0.3031l1.5906 0.9188c-63e-4 0.0906-94e-4 0.1843-94e-4 0.2812 0 0.0938 31e-4 0.1875 94e-4 0.2813L13.425 17.7c-0.1157 0.0656-0.2 0.175-0.2344 0.3031s-0.0157 0.2656 0.05 0.3781l1 1.7313c0.1375 0.2375 0.4437 0.3219 0.6812 0.1844l1.5907-0.9188c0.0781 0.0532 0.1562 0.1032 0.2406 0.1531 0.0844 0.05 0.1656 0.0938 0.25 0.1313V21.5c0 0.275 0.225 0.5 0.5 0.5h2c0.275 0 0.5-0.225 0.5-0.5v-1.8375c0.0812-0.0406 0.1656-0.0844 0.25-0.1313 0.0813-0.0468 0.1625-0.1 0.2406-0.1531l1.5907 0.9125c0.2375 0.1375 0.5437 0.0563 0.6812-0.1844l1-1.7312c0.0656-0.1156 0.0844-0.25 0.05-0.3781-0.0344-0.125-0.1187-0.2344-0.2344-0.3032zM22.1469 19.1781l-1.45-0.8312c-0.1844-0.1063-0.4157-0.0844-0.575 0.0562-0.1125 0.0969-0.2375 0.1875-0.3719 0.2656-0.1438 0.0813-0.2781 0.1438-0.4125 0.1907C19.1344 18.9281 19 19.1187 19 19.3312v1.6719h-1v-1.6719c0-0.2125-0.1344-0.4031-0.3375-0.4718-0.1344-0.0469-0.2719-0.1094-0.4125-0.1907-0.1344-0.0781-0.2594-0.1656-0.3719-0.2656-0.1625-0.1375-0.3938-0.1593-0.575-0.0562l-1.4469 0.8344-0.5-0.8657 1.4469-0.8343c0.1844-0.1063 0.2813-0.3188 0.2406-0.5282-0.0281-0.1406-0.0406-0.2937-0.0406-0.4531 0-0.1625 0.0125-0.3125 0.0406-0.45 0.0407-0.2093-0.0562-0.4219-0.2406-0.5281l-1.45-0.8344 0.5-0.8656 1.45 0.8313c0.1844 0.1062 0.4156 0.0843 0.575-0.0563 0.1125-0.0968 0.2375-0.1875 0.3719-0.2656 0.1437-0.0813 0.2781-0.1437 0.4125-0.1906C17.8656 14.0719 18 13.8813 18 13.6688V11.997h1v1.6718c0 0.2125 0.1344 0.4031 0.3375 0.4719 0.1344 0.0469 0.2719 0.1094 0.4125 0.1906 0.1344 0.0781 0.2594 0.1656 0.3719 0.2656 0.1625 0.1375 0.3937 0.1594 0.575 0.0563l1.4468-0.8344 0.5 0.8656-1.4468 0.8344c-0.1844 0.1062-0.2813 0.3187-0.2407 0.5281 0.0281 0.1406 0.0406 0.2937 0.0406 0.4531 0 0.1625-0.0125 0.3125-0.0406 0.45-0.0406 0.2094 0.0563 0.4219 0.2407 0.5281l1.4468 0.8344z" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.825 5.1875c-0.3031-1.0594-0.9844-2.1563-1.9188-3.0938-0.9343-0.9343-2.0343-1.6156-3.0937-1.9187-1.2438-0.3563-2.3688-0.15-3.0781 0.5594v0l-5 5C10.1313 6.3375 9.8906 7.2 10.0406 8.2l-4.4312 4.4094c-0.5469 0.5468-0.7282 1.3656-0.525 2.2718l-4.6032 4.6031c-31e-4 32e-4-31e-4 32e-4-62e-4 63e-4-0.4469 0.4531-0.5875 1.1344-0.3813 1.8687 0.1531 0.5438 0.4906 1.1032 0.9532 1.5719C1.7062 23.6 2.5281 24 3.2531 24v0c0.475 0 0.9094-0.1719 1.2188-0.4844l4.6093-4.6094c0.9219 0.2156 1.7563 0.0375 2.3094-0.5156l4.4313-4.4281c0.3468 0.0468 0.6781 0.05 0.9906 62e-4 0.5719-0.0812 1.075-0.325 1.4531-0.7031l5-5c0.7125-0.7125 0.9156-1.8344 0.5594-3.0781zM16.8531 11.8531c-0.0625 0.0625-0.1593 0.1063-0.2812 0.1313-0.15-0.0188-0.3063-0.05-0.4719-0.0969-0.8187-0.2344-1.6875-0.7812-2.4469-1.5375-0.7593-0.7594-1.3031-1.625-1.5375-2.4438-0.0468-0.1687-0.0812-0.3281-0.1-0.4812 0.025-0.1156 0.0688-0.2094 0.1313-0.2719l3.1-3.1c0.3312 0.9844 0.9812 1.9906 1.8469 2.8594 0.3937 0.3937 0.8187 0.7437 1.2562 1.0375l-2.5562 2.55 0.7062 0.7062 2.7438-2.7437C19.4781 8.575 19.7156 8.675 19.95 8.7531zM18.2656 2.0969c0.7282 0.2062 1.5406 0.7219 2.2282 1.4094 0.6875 0.6874 1.2031 1.5 1.4093 2.2281 0.1469 0.5093 0.125 0.9375-0.05 1.1156-0.1781 0.1781-0.6062 0.1969-1.1156 0.05-0.7281-0.2062-1.5406-0.7219-2.2281-1.4094s-1.2031-1.5-1.4094-2.2281c-0.1469-0.5094-0.125-0.9375 0.05-1.1156v0c0.175-0.175 0.6031-0.1938 1.1156-0.05zM11.6719 9.3938c0.3187 0.5656 0.7469 1.1312 1.275 1.6593 0.5344 0.5344 1.1031 0.9625 1.675 1.2844l-4.6282 4.625C9.45 16.85 8.7625 16.4813 8.1375 15.8594c-0.6219-0.6188-0.9906-1.3063-1.1031-1.8469zM3.1719 21.9875c-0.1438-0.0375-0.4157-0.1688-0.7-0.4594-0.3032-0.3094-0.4282-0.6031-0.4625-0.7469l4.8593-4.8593c0.1657 0.2219 0.3563 0.4406 0.5594 0.6437 0.1969 0.1969 0.4063 0.3781 0.6219 0.5407z" />
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M22.6031 5.2031c-0.25-0.1875-0.575-0.25-0.8781-0.1625-2.8625 0.8188-5.3187 1.1125-7.8875 0.8875 0.0969-1.0625 0.15-2.2062 0.1625-3.4187 31e-4-0.1625-0.075-0.3156-0.2063-0.4094-0.1312-0.0937-0.3-0.1219-0.4531-0.0687-3.875 1.2906-7.8062 1.2906-11.6843 0-0.1532-0.05-0.3219-0.025-0.4532 0.0687C1.0719 2.1937 0.9969 2.3438 1 2.5062c0.0469 4.4063 0.6375 7.8 1.7594 10.0875 0.5594 1.1407 1.2531 2.0094 2.0625 2.5782C5.6031 15.7219 6.5063 16 7.5 16c0.5313 0 1.0375-0.0813 1.5125-0.2375 0.2344 0.8531 0.5125 1.6156 0.8344 2.2906 0.6219 1.3156 1.425 2.3156 2.3844 2.975C13.1719 21.6719 14.2719 22 15.5 22s2.3281-0.3281 3.2688-0.9719c0.9593-0.6594 1.7625-1.6594 2.3843-2.975C22.3969 15.4312 23 11.4875 23 6c0-0.3125-0.1469-0.6094-0.3969-0.7969zM7.5 15c-0.7844 0-1.4937-0.2187-2.1031-0.6469-0.6719-0.4718-1.2563-1.2125-1.7406-2.2-0.9969-2.0344-1.5469-5.0468-1.6438-8.9656C3.8375 3.725 5.6781 4 7.5 4s3.6625-0.275 5.4875-0.8156c-0.0219 0.9219-0.0719 1.8-0.1437 2.625-1.1407-0.1563-2.3157-0.4156-3.5688-0.7719C8.9719 4.95 8.65 5.0125 8.3969 5.2 8.1469 5.3875 8 5.6844 8 5.9969 8 7.4656 8.0438 8.825 8.1312 10.0781 7.9313 10.025 7.7187 9.9969 7.5 9.9969c-1.3781 0-2.5 1.1219-2.5 2.5 0 0.275 0.225 0.5 0.5 0.5h2.9406c0.0938 0.6312 0.2032 1.225 0.3281 1.7875C8.375 14.9281 7.95 15 7.5 15zM8.3094 12H6.0875c0.2062-0.5812 0.7625-1 1.4156-1 0.2625 0 0.5063 0.0687 0.7219 0.1844 0.0219 0.2781 0.0531 0.55 0.0844 0.8156zM19.3469 17.1969C18.4406 19.1094 17.2188 20 15.5 20c-1.7187 0-2.9406-0.8906-3.8469-2.8031-1.0031-2.1157-1.5531-5.4407-1.6406-9.8938C11.9656 7.775 13.7375 8 15.5 8s3.5344-0.225 5.4875-0.6969c-0.0875 4.4532-0.6406 7.7782-1.6406 9.8938z" />
<path d="M17.5 15h-4c-0.275 0-0.5 0.225-0.5 0.5 0 1.3781 1.1219 2.5 2.5 2.5s2.5-1.1219 2.5-2.5c0-0.275-0.225-0.5-0.5-0.5zM15.5 17c-0.6531 0-1.2094-0.4188-1.4156-1h2.8281c-0.2031 0.5812-0.7594 1-1.4125 1z" />
<path d="M14.5 12.2062 15.2063 11.5l-1.3532-1.3531c-0.1937-0.1938-0.5125-0.1938-0.7062 0L11.7938 11.5 12.5 12.2062l1-1z" />
<path d="M17.5 11.2062l1 1 0.7063-0.7062-1.3532-1.3531c-0.1937-0.1938-0.5125-0.1938-0.7062 0L15.7938 11.5 16.5 12.2062z" />
<path d="M5.5 8c0.1281 0 0.2563-0.05 0.3531-0.1469L7.2063 6.5 6.5 5.7938l-1 1-1-1L3.7938 6.5 5.1469 7.8531C5.2437 7.95 5.3719 8 5.5 8z" />
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.7063 9.2937 20.975 6.5625c-0.3906-0.3906-1.025-0.3906-1.4156 0C19.2781 6.8438 18.9 7 18.5 7s-0.7781-0.1562-1.0594-0.4406c-0.5843-0.5844-0.5843-1.5375 0-2.1219 0.3907-0.3906 0.3907-1.025 0-1.4156l-2.7343-2.7281C14.5188 0.1063 14.2656 0 14 0s-0.5187 0.1063-0.7062 0.2938l-13 13c-0.3907 0.3906-0.3907 1.025 0 1.4156l2.7312 2.7312c0.1875 0.1875 0.4406 0.2938 0.7062 0.2938 0.2657 0 0.5188-0.1063 0.7063-0.2938C4.7219 17.1563 5.0969 17 5.4969 17s0.7781 0.1563 1.0593 0.4406C6.8375 17.725 7 18.1 7 18.5s-0.1563 0.7781-0.4406 1.0594C6.3719 19.7469 6.2656 20 6.2656 20.2656c0 0.2657 0.1063 0.5188 0.2938 0.7063l2.7312 2.7312c0.1938 0.1938 0.45 0.2938 0.7062 0.2938 0.2563 0 0.5126-0.0969 0.7063-0.2938l13-13c0.3937-0.3875 0.3937-1.0187 32e-4-1.4094zM10 21.5844 8.5781 20.1625c0.7156-1.325 0.5157-3.0219-0.6031-4.1406C7.3125 15.3656 6.4344 15 5.5 15c0 0 0 0 0 0-0.5906 0-1.1594 0.1469-1.6656 0.4187L2.4156 14 14 2.4156l1.4219 1.4219c-0.7156 1.325-0.5156 3.0219 0.6031 4.1406 0.6813 0.6813 1.5781 1.025 2.475 1.025 0.575 0 1.1469-0.1406 1.6656-0.4187l1.4219 1.4218z" />
<path d="M12.8531 6.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0l-6 6c-0.1938 0.1937-0.1938 0.5125 0 0.7062l5 5C11.2437 17.95 11.3719 18 11.5 18s0.2562-0.05 0.3531-0.1469l6-6c0.1938-0.1937 0.1938-0.5125 0-0.7062zM11.5 16.7937 7.2063 12.5 12.5 7.2062 16.7938 11.5z" />
</svg>
</template>

View File

@@ -1,13 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M9.5469 19.05c-1.2657 1.2656-3.3282 1.2656-4.5938 0s-1.2656-3.3281-31e-4-4.5937l2.0063-1.9969-1.4094-1.4188-2.0094 2c-2.0469 2.0469-2.0469 5.3782 0 7.425C4.5594 21.4875 5.9031 22 7.25 22c1.3438 0 2.6906-0.5125 3.7156-1.5375l1.9969-2.0062-1.4187-1.4094z" />
<path d="M19.05 9.5438l-2.0062 1.9968 1.4093 1.4188 2.0094-2c2.0469-2.0469 2.0469-5.3782 0-7.425-2.0469-2.0469-5.3781-2.0469-7.425 31e-4l-1.9969 2.0062 1.4188 1.4094 1.9937-2.0031c1.2656-1.2656 3.3281-1.2656 4.5938 0 1.2687 1.2687 1.2687 3.3281 31e-4 4.5938z" />
<polygon points="16.791656 17.499 17.498766 16.791891 20.205633 19.498758 19.498523 20.205867" />
<rect width="1.000008" height="4.000008" x="15" y="18" />
<rect width="4.000008" height="1.000008" x="18" y="15" />
<polygon points="3.791836 4.499016 4.498945 3.791906 7.205812 6.498773 6.498703 7.205883" />
<rect width="1.000008" height="4.000008" x="7.999992" y="1.999992" />
<rect width="4.000008" height="1.000008" x="1.999992" y="7.999992" />
</svg>
</template>

View File

@@ -1,9 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.7875 21.1031l-10-19.9969C13.45 0.425 12.7625 0 12 0c-0.7657 0-1.45 0.425-1.7875 1.1062l-10 19.9969C0.075 21.3781 0 21.6875 0 22c0 1.1031 0.8968 2 1.9999 2h20c1.1032 0 2-0.8969 2-2 0-0.3125-0.075-0.6219-0.2124-0.8969zM2 22 12 2c0 0 0 0 0 0l10 20zM22 22c0 0 0 0 0 0s31e-4 0 0 0c31e-4 0 0 0 0 0z" />
<path d="M11.5531 5.1281 3.9812 20.275c-0.0781 0.1563-0.0687 0.3375 0.0219 0.4875C4.0937 20.9094 4.2562 21 4.4281 21H9v-1H5.2375L12 6.4719 18.7625 20H15v1h4.5719c0.1718 0 0.3343-0.0906 0.425-0.2375 0.0906-0.1469 0.1-0.3312 0.0218-0.4875L12.4468 5.1281C12.3625 4.9594 12.1875 4.8531 12 4.8531c-0.1907 0-0.3626 0.1063-0.4469 0.275z" />
<rect width="1.999992" height="7.000008" x="10.999992" y="10.999992" />
<rect width="1.999992" height="1.999992" x="10.999992" y="19.000008" />
</svg>
</template>

View File

@@ -1,6 +0,0 @@
<template>
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
<path d="M23.3625 15.575c-0.3625-0.5-0.8844-0.9563-1.55-1.3563-1-0.6031-2.3625-1.1093-4.1531-1.5468l-2.5563-8.4875c-0.1781-0.5938-0.6312-1.0782-1.2125-1.2969L6.35 0.0625c-0.3969-0.15-0.8469-0.0312-1.1187 0.2969C4.9594 0.6875 4.9219 1.15 5.1406 1.5125L7.9219 6.15 6.1625 12.7188c-1.7375 0.4406-3.0594 0.9469-4.025 1.5375C0.7188 15.1219 0 16.2125 0 17.5c0 0.9125 0.3844 1.8375 1.1063 2.6782 0.6437 0.7468 1.5375 1.4187 2.6562 1.9968C6.0375 23.35 8.9656 24 12 24s5.9625-0.6499 8.2375-1.825c1.1188-0.5781 2.0125-1.25 2.6563-1.9968C23.6188 19.3375 24 18.4125 24 17.5c0-0.6937-0.2156-1.3406-0.6375-1.925zM16.975 17.3344v0c-31e-4 0-31e-4 0 0 0-3.2656 0.8969-6.7156 0.8937-9.975-94e-4v0l0.4969-1.8562C8.2219 15.6687 9.075 15.8187 10 15.9094v0.5937c0 0.275 0.225 0.5 0.5 0.5h3c0.275 0 0.5-0.225 0.5-0.5v-0.5937c0.8875-0.0875 1.7125-0.2313 2.4156-0.4219zM11 16v-1h2v1zM9.6406 5.1219 8.3344 2.9437l4.8531 1.8188 2.9438 9.7656c-0.6188 0.1657-1.3407 0.2907-2.1282 0.3719v-0.4c0-0.275-0.225-0.5-0.5-0.5h-3c-0.275 0-0.5 0.225-0.5 0.5v0.4031c-0.8374-0.0843-1.6031-0.2218-2.2468-0.4l2.1-7.8344c0.1406-0.5218 0.0593-1.0843-0.2157-1.5468zM19.3188 20.3969C17.35 21.4156 14.6813 22 12 22s-5.35-0.5844-7.3188-1.6031C3.0281 19.5406 2 18.4313 2 17.5c0-0.95 1.2219-1.8156 3.5594-2.5344l-0.4938 1.8438c-0.2844 1.0563 0.3406 2.1531 1.3938 2.4438C8.2469 19.7469 10.1031 20 11.9781 20c63e-4 0 0.0157 0 0.0219 0 1.8687 0 3.7188-0.2468 5.5-0.7375 0.5281-0.1437 0.9625-0.4875 1.225-0.9625s0.3219-1.0219 0.1625-1.5437l-0.55-1.8282C20.7406 15.6469 22 16.525 22 17.5c0 0.9313-1.0281 2.0406-2.6812 2.8969z" />
</svg>
</template>

View File

@@ -3,5 +3,4 @@ import type { IList } from "./IList";
export default interface ISection {
title: string;
apiFunction: (page: number) => Promise<IList>;
sectionType?: "list" | "discover";
}

View File

@@ -17,7 +17,7 @@ export interface CookieOptions {
* Read a cookie value.
*/
export function getAuthorizationCookie(): string | null {
const key = "authorization";
const key = 'authorization';
const array = document.cookie.split(";");
let match = null;
@@ -127,9 +127,6 @@ const userModule: Module<UserState, RootState> = {
state.settings = null;
state.admin = false;
deleteCookie("authorization");
deleteCookie("plex_auth_token");
localStorage.clear();
sessionStorage.clear();
}
},

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;

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

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

View File

@@ -1,514 +0,0 @@
<template>
<section class="discover-page">
<div class="hero-section">
<div class="hero-collage">
<div
v-for="(poster, index) in heroPosters"
:key="index"
class="poster-tile"
:style="{ backgroundImage: `url(${poster})` }"
></div>
</div>
<div class="hero-overlay"></div>
<div class="hero-content-wrapper">
<div class="discover-header">
<h1>Discover Movies</h1>
<p class="discover-subtitle">
Explore curated collections across genres, eras, and moods
</p>
</div>
<DiscoverShowcase
:active-category="activeCategory"
@select="updateCategory"
/>
</div>
</div>
<div class="discover-content">
<ResultsSection
v-for="list in activeLists"
:key="list.id"
:api-function="list.apiFunction"
:title="list.title"
:short-list="true"
section-type="discover"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import DiscoverShowcase from "@/components/DiscoverShowcase.vue";
import { getTmdbMovieDiscoverByName } from "../api";
import type { Ref } from "vue";
interface DiscoverList {
id: string;
title: string;
apiFunction: (page: number) => Promise<any>;
category: string;
}
const route = useRoute();
const router = useRouter();
const activeCategory: Ref<string> = ref(
(route.query.category as string) || "popular"
);
const heroPosters: Ref<string[]> = ref([]);
// Update URL when category changes
function updateCategory(categoryId: string) {
activeCategory.value = categoryId;
router.push({ query: { category: categoryId } });
}
// Watch for query parameter changes (e.g., browser back/forward)
watch(
() => route.query.category,
newCategory => {
if (newCategory && newCategory !== activeCategory.value) {
activeCategory.value = newCategory as string;
}
}
);
// Fetch popular movies for hero collage
onMounted(async () => {
// Scroll to top when component mounts - Safari compatible
// Use requestAnimationFrame to ensure it runs after render
requestAnimationFrame(() => {
return;
window.scrollTo(0, 0);
// Also try scrolling the body element for Safari compatibility
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
});
try {
const response = await getTmdbMovieDiscoverByName("recent_releases");
const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";
// Take first 12 movies and shuffle them for variety
const posters = response.results
.slice(0, 12)
.map((movie: any) =>
movie.poster ? IMAGE_BASE_URL + movie.poster : null
)
.filter((poster: string | null) => poster !== null);
heroPosters.value = posters;
} catch (error) {
console.error("Failed to load hero posters:", error);
}
});
const discoverLists: DiscoverList[] = [
// Popular
{
id: "popular_now",
title: "Popular Now",
apiFunction: () => getTmdbMovieDiscoverByName("popular_now"),
category: "popular"
},
{
id: "top_rated",
title: "Top Rated",
apiFunction: () => getTmdbMovieDiscoverByName("top_rated"),
category: "popular"
},
{
id: "critics_choice",
title: "Critics' Choice",
apiFunction: () => getTmdbMovieDiscoverByName("critics_choice"),
category: "popular"
},
{
id: "recent_releases",
title: "Recent Releases",
apiFunction: () => getTmdbMovieDiscoverByName("recent_releases"),
category: "popular"
},
{
id: "crowd_pleasers",
title: "Crowd Pleasers",
apiFunction: () => getTmdbMovieDiscoverByName("crowd_pleasers"),
category: "popular"
},
// Genres
{
id: "action_packed",
title: "Action Packed",
apiFunction: () => getTmdbMovieDiscoverByName("action_packed"),
category: "genres"
},
{
id: "sci_fi_wonders",
title: "Sci-Fi Wonders",
apiFunction: () => getTmdbMovieDiscoverByName("sci_fi_wonders"),
category: "genres"
},
{
id: "horror_hits",
title: "Horror Hits",
apiFunction: () => getTmdbMovieDiscoverByName("horror_hits"),
category: "genres"
},
{
id: "romantic_favorites",
title: "Romantic Favorites",
apiFunction: () => getTmdbMovieDiscoverByName("romantic_favorites"),
category: "genres"
},
{
id: "laugh_out_loud",
title: "Laugh Out Loud",
apiFunction: () => getTmdbMovieDiscoverByName("laugh_out_loud"),
category: "genres"
},
{
id: "animated_magic",
title: "Animated Magic",
apiFunction: () => getTmdbMovieDiscoverByName("animated_magic"),
category: "genres"
},
{
id: "fantasy_worlds",
title: "Fantasy Worlds",
apiFunction: () => getTmdbMovieDiscoverByName("fantasy_worlds"),
category: "genres"
},
{
id: "thriller_edge",
title: "Thriller's Edge",
apiFunction: () => getTmdbMovieDiscoverByName("thriller_edge"),
category: "genres"
},
{
id: "crime_dramas",
title: "Crime Dramas",
apiFunction: () => getTmdbMovieDiscoverByName("crime_dramas"),
category: "genres"
},
{
id: "westerns",
title: "Westerns",
apiFunction: () => getTmdbMovieDiscoverByName("westerns"),
category: "genres"
},
{
id: "war_epics",
title: "War Epics",
apiFunction: () => getTmdbMovieDiscoverByName("war_epics"),
category: "genres"
},
{
id: "dark_comedy",
title: "Dark Comedy",
apiFunction: () => getTmdbMovieDiscoverByName("dark_comedy"),
category: "genres"
},
{
id: "musical_magic",
title: "Musical Magic",
apiFunction: () => getTmdbMovieDiscoverByName("musical_magic"),
category: "genres"
},
// Moods & Themes
{
id: "feel_good",
title: "Feel Good",
apiFunction: () => getTmdbMovieDiscoverByName("feel_good"),
category: "moods"
},
{
id: "mind_benders",
title: "Mind Benders",
apiFunction: () => getTmdbMovieDiscoverByName("mind_benders"),
category: "moods"
},
{
id: "epic_movies",
title: "Epic Movies",
apiFunction: () => getTmdbMovieDiscoverByName("epic_movies"),
category: "moods"
},
{
id: "quick_picks",
title: "Quick Picks",
apiFunction: () => getTmdbMovieDiscoverByName("quick_picks"),
category: "moods"
},
{
id: "family_night",
title: "Family Night",
apiFunction: () => getTmdbMovieDiscoverByName("family_night"),
category: "moods"
},
{
id: "true_stories",
title: "True Stories",
apiFunction: () => getTmdbMovieDiscoverByName("true_stories"),
category: "moods"
},
{
id: "coming_of_age",
title: "Coming of Age",
apiFunction: () => getTmdbMovieDiscoverByName("coming_of_age"),
category: "moods"
},
// Decades
{
id: "golden_age",
title: "Golden Age",
apiFunction: () => getTmdbMovieDiscoverByName("golden_age"),
category: "decades"
},
{
id: "90s_nostalgia",
title: "90s Nostalgia",
apiFunction: () => getTmdbMovieDiscoverByName("90s_nostalgia"),
category: "decades"
},
{
id: "2000s_classics",
title: "2000s Classics",
apiFunction: () => getTmdbMovieDiscoverByName("2000s_classics"),
category: "decades"
},
{
id: "2010s_best",
title: "2010s Best",
apiFunction: () => getTmdbMovieDiscoverByName("2010s_best"),
category: "decades"
},
// Special Collections
{
id: "blockbusters",
title: "Blockbusters",
apiFunction: () => getTmdbMovieDiscoverByName("blockbusters"),
category: "special"
},
{
id: "hidden_gems",
title: "Hidden Gems",
apiFunction: () => getTmdbMovieDiscoverByName("hidden_gems"),
category: "special"
},
{
id: "modern_classics",
title: "Modern Classics",
apiFunction: () => getTmdbMovieDiscoverByName("modern_classics"),
category: "special"
},
{
id: "indie_darlings",
title: "Indie Darlings",
apiFunction: () => getTmdbMovieDiscoverByName("indie_darlings"),
category: "special"
},
{
id: "international_cinema",
title: "International Cinema",
apiFunction: () => getTmdbMovieDiscoverByName("international_cinema"),
category: "special"
},
{
id: "oscar_winners",
title: "Oscar Winners",
apiFunction: () => getTmdbMovieDiscoverByName("oscar_winners"),
category: "special"
},
{
id: "space_odyssey",
title: "Space Odyssey",
apiFunction: () => getTmdbMovieDiscoverByName("space_odyssey"),
category: "special"
},
{
id: "superhero_saga",
title: "Superhero Saga",
apiFunction: () => getTmdbMovieDiscoverByName("superhero_saga"),
category: "special"
},
{
id: "heist_films",
title: "Heist Films",
apiFunction: () => getTmdbMovieDiscoverByName("heist_films"),
category: "special"
},
{
id: "zombies_apocalypse",
title: "Zombies & Apocalypse",
apiFunction: () => getTmdbMovieDiscoverByName("zombies_apocalypse"),
category: "special"
},
{
id: "time_travel",
title: "Time Travel",
apiFunction: () => getTmdbMovieDiscoverByName("time_travel"),
category: "special"
}
];
const activeLists = computed(() => {
return discoverLists.filter(list => list.category === activeCategory.value);
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.discover-page {
background-color: var(--background-color);
min-height: 100vh;
}
.hero-section {
position: relative;
overflow: hidden;
min-height: 400px;
@include mobile {
min-height: 350px;
}
}
.hero-content-wrapper {
position: relative;
z-index: 1;
}
.discover-header {
padding: 4rem 1.5rem 2rem;
text-align: center;
@include mobile {
padding: 3rem 1rem 1.5rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 3rem;
font-weight: 700;
color: #ffffff;
text-shadow:
0 3px 15px rgba(0, 0, 0, 0.8),
0 1px 3px rgba(0, 0, 0, 0.6);
letter-spacing: -0.5px;
@include mobile {
font-size: 2rem;
}
}
.discover-subtitle {
margin: 0;
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.95);
font-weight: 300;
text-shadow:
0 2px 10px rgba(0, 0, 0, 0.8),
0 1px 3px rgba(0, 0, 0, 0.6);
@include mobile {
font-size: 0.95rem;
}
}
}
.hero-collage {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 10px;
padding: 10px;
opacity: 0.4;
filter: blur(0px);
@include mobile {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
padding: 8px;
}
}
.poster-tile {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 6px;
animation: fadeIn 0.8s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
&:nth-child(even) {
animation-delay: 0.1s;
}
&:nth-child(3n) {
animation-delay: 0.2s;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(var(--background-color-rgb, 18, 18, 18), 0.6) 0%,
rgba(var(--background-color-rgb, 18, 18, 18), 0.7) 50%,
rgba(var(--background-color-rgb, 18, 18, 18), 0.6) 100%
);
backdrop-filter: blur(0px);
}
.discover-content {
padding: 0;
background-color: var(--background-color);
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
}
}
</style>

View File

@@ -2,40 +2,38 @@
<section>
<LandingBanner />
<ResultsSection
:api-function="getRequests"
title="Requests"
:short-list="true"
/>
<ResultsSection
:api-function="() => getTmdbMovieListByName('now_playing')"
title="Now playing"
:short-list="true"
section-type="list"
/>
<DiscoverMinimal />
<ResultsSection
:api-function="() => getTmdbMovieListByName('upcoming')"
title="Upcoming"
:short-list="true"
section-type="list"
/>
<ResultsSection
:api-function="() => getTmdbMovieListByName('popular')"
title="Popular"
:short-list="true"
section-type="list"
/>
<div v-for="list in lists" :key="list.title">
<ResultsSection
:api-function="list.apiFunction"
:title="list.title"
:short-list="true"
/>
</div>
</section>
</template>
<script setup lang="ts">
import LandingBanner from "@/components/LandingBanner.vue";
import ResultsSection from "@/components/ResultsSection.vue";
import DiscoverMinimal from "@/components/DiscoverMinimal.vue";
import { getRequests, getTmdbMovieListByName } from "../api";
import type ISection from "../interfaces/ISection";
const lists: ISection[] = [
{
title: "Requests",
apiFunction: getRequests
},
{
title: "Now playing",
apiFunction: () => getTmdbMovieListByName("now_playing")
},
{
title: "Upcoming",
apiFunction: () => getTmdbMovieListByName("upcoming")
},
{
title: "Popular",
apiFunction: () => getTmdbMovieListByName("popular")
}
];
</script>

30
src/pages/ListPage.vue Normal file
View File

@@ -0,0 +1,30 @@
<template>
<ResultsSection :title="listName" :api-function="_getTmdbMovieListByName" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { Ref } from "vue";
import { useRoute } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import { getTmdbMovieListByName } from "../api";
const route = useRoute();
const listName: Ref<string | string[]> = ref(
route?.params?.name || "List page"
);
function _getTmdbMovieListByName(page: number) {
return getTmdbMovieListByName(listName.value?.toString(), page);
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</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>
<seasoned-input
v-model="username"
placeholder="Email address"
icon="Email"
type="email"
@keydown.enter="focusOnNextElement"
/>
<form ref="formElement" class="form">
<seasoned-input
v-model="username"
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"
icon="Keyhole"
type="password"
@keydown.enter="focusOnNextElement"
/>
<seasoned-input
v-model="passwordRepeat"
placeholder="repeat password"
icon="Keyhole"
type="password"
@keydown.enter="submit"
/>
<seasoned-input
v-model="password"
placeholder="Password"
icon="Keyhole"
type="password"
class="password-input"
@keydown.enter="focusOnNextElement"
/>
<seasoned-button @click="submit">Register</seasoned-button>
</form>
<seasoned-input
v-model="passwordRepeat"
placeholder="Confirm password"
icon="Keyhole"
type="password"
class="password-input"
@keydown.enter="submit"
/>
</div>
<router-link class="link" to="/login"
>Have a user? Sign in here</router-link
>
<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>
</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>
<seasoned-messages v-model:messages="messages"></seasoned-messages>
</div>
</div>
<seasoned-messages v-model:messages="messages"></seasoned-messages>
</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");
}
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;
function registerUser() {
register(username.value, password.value)
.then(data => {
if (data?.success && store.dispatch("user/login")) {
router.push({ name: "profile" });
}
})
.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;
}
}
.register__password-section {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form > div,
input,
button {
margin-bottom: 1rem;
.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);
&:last-child {
margin-bottom: 0px;
}
}
.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

@@ -1,40 +0,0 @@
<template>
<ResultsSection :title="sectionName" :api-function="_getSectionData" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { Ref } from "vue";
import { useRoute } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import { getTmdbMovieListByName, getTmdbMovieDiscoverByName } from "../api";
const route = useRoute();
const sectionName: Ref<string | string[]> = ref(
route?.params?.name || "Section page"
);
// Determine if this is a discover section or a list based on the route path
const isDiscoverSection = route.path.startsWith("/discover/");
function _getSectionData(page: number) {
const name = sectionName.value?.toString();
// Use the appropriate API function based on the route type
if (isDiscoverSection) {
return getTmdbMovieDiscoverByName(name, page);
} else {
return getTmdbMovieListByName(name, page);
}
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</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">
<seasoned-input
v-model="username"
placeholder="Email address"
icon="Email"
type="email"
@keydown.enter="focusOnNextElement"
/>
<seasoned-input
v-model="password"
placeholder="Password"
icon="Keyhole"
type="password"
@keydown.enter="submit"
/>
<form ref="formElement" class="form">
<seasoned-input
v-model="username"
placeholder="username"
icon="Email"
type="email"
@keydown.enter="focusOnNextElement"
/>
<seasoned-input
v-model="password"
placeholder="password"
icon="Keyhole"
type="password"
@keydown.enter="submit"
/>
<seasoned-button class="auth-button" @click="submit">
Sign In
</seasoned-button>
</form>
<seasoned-button @click="submit">sign in</seasoned-button>
</form>
<router-link class="link" to="/register"
>Don't have a user? Register here</router-link
>
<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>
<seasoned-messages v-model:messages="messages" />
</div>
</div>
<seasoned-messages v-model:messages="messages" />
</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

@@ -4,7 +4,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 {
@@ -39,17 +38,7 @@ const routes: RouteRecordRaw[] = [
{
name: "list",
path: "/list/:name",
component: () => import("./pages/SectionPage.vue")
},
{
name: "discover-section",
path: "/discover/:name",
component: () => import("./pages/SectionPage.vue")
},
{
name: "discover",
path: "/discover",
component: () => import("./pages/DiscoverPage.vue")
component: () => import("./pages/ListPage.vue")
},
{
name: "search",
@@ -90,6 +79,12 @@ const routes: RouteRecordRaw[] = [
path: "/password",
component: () => import("./pages/GenPasswordPage.vue")
},
{
name: "admin",
path: "/admin",
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
{
name: "missing-plex-auth",
path: "/missing/plex",
@@ -114,13 +109,7 @@ const router = createRouter({
history: createWebHistory("/"),
// base: "/",
routes,
linkActiveClass: "is-active",
scrollBehavior(to, from, savedPosition) {
if (to.name !== "discover") return;
console.log("scrolling top");
return { top: 0 };
}
linkActiveClass: "is-active"
});
const loggedIn = () => store.getters["user/loggedIn"];

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

@@ -89,9 +89,8 @@ 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}` : ""
}${window.location.pathname}${params.toString().length ? `?${params}` : ""}`;
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);
}
@@ -142,5 +141,5 @@ export function formatBytes(bytes: number): string {
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]}`;
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}

View File

@@ -1940,9 +1940,9 @@ flat-cache@^4.0.0:
keyv "^4.5.4"
flatted@^3.2.9:
version "3.4.2"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
version "3.3.3"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
for-each@^0.3.3, for-each@^0.3.5:
version "0.3.5"