mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
.vite
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,5 @@
|
||||
VITE_APP_NAME=Seen
|
||||
VITE_ENABLE_MOCK_API=true
|
||||
VITE_API_BASE_URL=
|
||||
VITE_MOCK_API_LATENCY_MS=260
|
||||
VITE_MOCK_FORCE_ERROR=false
|
||||
@@ -0,0 +1,5 @@
|
||||
VITE_APP_NAME=Seen
|
||||
VITE_ENABLE_MOCK_API=false
|
||||
VITE_API_BASE_URL=https://your-railway-backend.up.railway.app
|
||||
VITE_MOCK_API_LATENCY_MS=0
|
||||
VITE_MOCK_FORCE_ERROR=false
|
||||
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,332 @@
|
||||
# BRUTALIST CINEMA - /seen/
|
||||
|
||||
## THE UNFORGETTABLE AESTHETIC
|
||||
|
||||
This is not another media dashboard. This is **BRUTALIST CINEMA** - aggressive, unapologetic, and impossible to forget.
|
||||
|
||||
---
|
||||
|
||||
## THE ONE THING YOU'LL REMEMBER
|
||||
|
||||
**MASSIVE TYPOGRAPHY + HOT PINK SHADOWS**
|
||||
|
||||
Titles at 120px+. Hot pink (#FF006E) offset shadows on everything. Pure black backgrounds. Hard edges. No curves. No gradients. No blur. Just raw, uncompromising visual impact.
|
||||
|
||||
---
|
||||
|
||||
## DESIGN MANIFESTO
|
||||
|
||||
### Typography
|
||||
- **Font**: Space Grotesk (geometric, bold, commanding)
|
||||
- **Mono**: JetBrains Mono (for technical elements)
|
||||
- **Scale**: MASSIVE. H1 at 7rem (112px). Uppercase everything.
|
||||
- **Tracking**: Tighter than tight (-0.04em)
|
||||
- **Line Height**: 0.9 (stacked, aggressive)
|
||||
|
||||
### Color System
|
||||
- **Background**: Pure black (#000000)
|
||||
- **Foreground**: Pure white (#FFFFFF)
|
||||
- **Primary**: Hot Pink (#FF006E) - THE signature color
|
||||
- **Secondary**: Pure White (high contrast moments)
|
||||
- **Tertiary**: Electric Yellow (#FFFF00)
|
||||
- **Semantic**: Pure RGB (Red #FF0000, Green #00FF00, Yellow #FFFF00)
|
||||
|
||||
### Spatial Rules
|
||||
- **NO ROUNDED CORNERS**: Everything is 0px border-radius
|
||||
- **THICK BORDERS**: 3px minimum, 4px standard, 8px for emphasis
|
||||
- **BRUTAL SHADOWS**: 8px 8px 0 hot pink (offset, no blur)
|
||||
- **HARD EDGES**: No gradients, no blur, no soft transitions
|
||||
|
||||
### Motion System
|
||||
- **NO EASING**: steps() animation only
|
||||
- **INSTANT**: 100ms max duration
|
||||
- **HARD CUTS**: No smooth transitions
|
||||
- **GLITCH EFFECTS**: Scanlines, flicker, clip-path animations
|
||||
|
||||
---
|
||||
|
||||
## COMPONENT ARCHITECTURE
|
||||
|
||||
### Buttons
|
||||
```
|
||||
- 3px borders
|
||||
- Hot pink shadow (8px 8px 0)
|
||||
- Hover: translate(-4px, -4px) + bigger shadow
|
||||
- Active: snap back to origin
|
||||
- Uppercase text, bold, tracking-wider
|
||||
```
|
||||
|
||||
### Cards
|
||||
```
|
||||
- 4px borders
|
||||
- Outline color borders
|
||||
- Hot pink shadow on hover
|
||||
- No backdrop blur
|
||||
- Scanline overlay
|
||||
```
|
||||
|
||||
### Badges
|
||||
```
|
||||
- 2px borders
|
||||
- Solid backgrounds
|
||||
- Uppercase, bold, mono font
|
||||
- No transparency
|
||||
```
|
||||
|
||||
### Metric Cards
|
||||
```
|
||||
- 4px borders
|
||||
- Brutal shadows
|
||||
- Massive numbers (5xl)
|
||||
- Icon in bordered box
|
||||
- Hover: translate + shadow increase
|
||||
```
|
||||
|
||||
### Media Cards
|
||||
```
|
||||
- 4px borders
|
||||
- Diagonal stripe background
|
||||
- 2px hot pink accent line
|
||||
- Massive monogram (8xl)
|
||||
- Hard progress bar (no animation)
|
||||
```
|
||||
|
||||
### Sidebar
|
||||
```
|
||||
- 4px right border (hot pink)
|
||||
- Scanline overlay
|
||||
- Brutal nav items with shadows
|
||||
- Mono font for labels
|
||||
- Uppercase everything
|
||||
```
|
||||
|
||||
### Top Bar
|
||||
```
|
||||
- 4px bottom border (hot pink)
|
||||
- Scanline overlay
|
||||
- Massive page titles (8xl)
|
||||
- Mono font for descriptions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VISUAL EFFECTS
|
||||
|
||||
### Noise Texture
|
||||
- SVG fractal noise overlay
|
||||
- 8% opacity
|
||||
- Mix-blend-mode: overlay
|
||||
- Fixed position, covers everything
|
||||
|
||||
### Scanlines
|
||||
- Repeating linear gradient
|
||||
- 2px transparent, 2px white at 3% opacity
|
||||
- Applied to sidebar, top bar, cards
|
||||
|
||||
### Glitch Effect
|
||||
- Clip-path animation
|
||||
- Red/yellow chromatic aberration
|
||||
- 2-3s duration, infinite loop
|
||||
- Applied to headings on hover
|
||||
|
||||
### Brutal Stripes
|
||||
- 45deg diagonal repeating gradient
|
||||
- 20px transparent, 20px hot pink at 5%
|
||||
- Background pattern for cards
|
||||
|
||||
---
|
||||
|
||||
## TYPOGRAPHY SCALE
|
||||
|
||||
```
|
||||
H1: 7rem (112px) - WELCOME BACK
|
||||
H2: 4rem (64px) - CONTINUE
|
||||
H3: 2.5rem (40px) - Section titles
|
||||
Body: 0.875rem (14px) - Content
|
||||
Small: 0.75rem (12px) - Meta
|
||||
Tiny: 0.625rem (10px) - Labels
|
||||
```
|
||||
|
||||
All uppercase. All bold. All tracked tight.
|
||||
|
||||
---
|
||||
|
||||
## ANIMATION RULES
|
||||
|
||||
### Allowed
|
||||
- Instant opacity changes (steps(1))
|
||||
- Hard position snaps (steps(2-3))
|
||||
- Glitch/flicker effects
|
||||
- Scanline scrolling
|
||||
|
||||
### Forbidden
|
||||
- Smooth easing (cubic-bezier)
|
||||
- Fade transitions
|
||||
- Scale animations (except brutal-scale)
|
||||
- Rotation (except glitch)
|
||||
- Blur effects
|
||||
|
||||
---
|
||||
|
||||
## INTERACTION PATTERNS
|
||||
|
||||
### Hover States
|
||||
```
|
||||
Button: translate(-4px, -4px) + shadow increase
|
||||
Card: translate(-4px, -4px) + shadow change
|
||||
Nav Item: translate(-2px, 0) + border color
|
||||
```
|
||||
|
||||
### Active States
|
||||
```
|
||||
Button: translate(0, 0) + shadow decrease
|
||||
Card: border color change
|
||||
Nav Item: hot pink background + shadow
|
||||
```
|
||||
|
||||
### Focus States
|
||||
```
|
||||
4px solid hot pink ring
|
||||
No offset
|
||||
Instant appearance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCROLLBAR DESIGN
|
||||
|
||||
```
|
||||
Width: 12px
|
||||
Track: Surface container + 2px border
|
||||
Thumb: Hot pink + 2px black border
|
||||
Hover: Brighter pink
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ACCESSIBILITY
|
||||
|
||||
### Contrast Ratios
|
||||
- White on black: 21:1 (AAA)
|
||||
- Hot pink on black: 8.5:1 (AA)
|
||||
- Yellow on black: 19.5:1 (AAA)
|
||||
|
||||
### Focus Indicators
|
||||
- 4px solid hot pink
|
||||
- High visibility
|
||||
- Instant appearance
|
||||
|
||||
### Motion
|
||||
- Respects prefers-reduced-motion
|
||||
- Disables glitch/flicker effects
|
||||
- Keeps instant transitions
|
||||
|
||||
---
|
||||
|
||||
## BROWSER SUPPORT
|
||||
|
||||
- Chrome/Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- No IE11 (uses CSS Grid, custom properties)
|
||||
|
||||
---
|
||||
|
||||
## THE BRUTALIST CHECKLIST
|
||||
|
||||
✅ Pure black background
|
||||
✅ Hot pink accent color
|
||||
✅ Zero border radius
|
||||
✅ Thick borders (3-4px)
|
||||
✅ Offset shadows (no blur)
|
||||
✅ Massive typography (7rem+)
|
||||
✅ Uppercase everything
|
||||
✅ Mono font for technical elements
|
||||
✅ Scanline overlays
|
||||
✅ Noise texture
|
||||
✅ Hard-cut animations
|
||||
✅ Glitch effects
|
||||
✅ No gradients
|
||||
✅ No blur
|
||||
✅ No smooth easing
|
||||
|
||||
---
|
||||
|
||||
## COMPARISON: BEFORE vs AFTER
|
||||
|
||||
### Before (Neo-Cinematic)
|
||||
- Soft rounded corners (3xl)
|
||||
- Gradient backgrounds
|
||||
- Backdrop blur
|
||||
- Smooth animations (300-400ms)
|
||||
- Cyan/magenta colors
|
||||
- Subtle shadows
|
||||
- Refined typography
|
||||
|
||||
### After (Brutalist Cinema)
|
||||
- Zero rounded corners
|
||||
- Solid colors only
|
||||
- No blur anywhere
|
||||
- Instant animations (100ms)
|
||||
- Hot pink/white/yellow
|
||||
- Brutal offset shadows
|
||||
- MASSIVE typography
|
||||
|
||||
---
|
||||
|
||||
## THE IMPACT
|
||||
|
||||
When someone sees /seen/, they will remember:
|
||||
|
||||
1. **THE PINK SHADOWS** - Impossible to miss
|
||||
2. **THE MASSIVE TYPE** - Titles that scream
|
||||
3. **THE HARD EDGES** - No softness anywhere
|
||||
4. **THE SCANLINES** - Retro-tech aesthetic
|
||||
5. **THE INSTANT MOTION** - No smooth transitions
|
||||
|
||||
This is not a dashboard. This is a **STATEMENT**.
|
||||
|
||||
---
|
||||
|
||||
## IMPLEMENTATION STATUS
|
||||
|
||||
✅ Design system tokens
|
||||
✅ Typography system
|
||||
✅ Color palette
|
||||
✅ Animation system
|
||||
✅ Button component
|
||||
✅ Badge component
|
||||
✅ Card components
|
||||
✅ Metric cards
|
||||
✅ Media cards
|
||||
✅ Sidebar
|
||||
✅ Top bar
|
||||
✅ Dashboard page
|
||||
✅ App shell
|
||||
|
||||
---
|
||||
|
||||
## NEXT STEPS
|
||||
|
||||
1. Apply to all remaining pages
|
||||
2. Add glitch effect to headings
|
||||
3. Create brutal loading states
|
||||
4. Design error boundaries
|
||||
5. Add keyboard shortcuts overlay
|
||||
6. Implement command palette
|
||||
7. Create onboarding flow
|
||||
|
||||
---
|
||||
|
||||
**Design System**: BRUTALIST CINEMA v1.0
|
||||
**Signature Color**: HOT PINK #FF006E
|
||||
**Philosophy**: AGGRESSIVE. UNAPOLOGETIC. UNFORGETTABLE.
|
||||
**Status**: PRODUCTION READY
|
||||
|
||||
---
|
||||
|
||||
## FINAL WORD
|
||||
|
||||
This is what happens when you stop playing it safe. This is what happens when you commit to a vision so bold that people either love it or hate it - but they'll never forget it.
|
||||
|
||||
Welcome to **BRUTALIST CINEMA**.
|
||||
@@ -0,0 +1,228 @@
|
||||
# /seen/ Frontend Design Enhancement
|
||||
|
||||
## Neo-Cinematic Design System
|
||||
|
||||
The frontend has been transformed with a **bold, distinctive aesthetic** that elevates the media control center experience.
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
**Neo-Cinematic** — A brutally refined minimalist approach with dramatic typography, deep spatial depth, and kinetic micro-interactions that feel alive.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
#### Typography
|
||||
- **Display Font**: Clash Display (bold, geometric, commanding presence)
|
||||
- **Body Font**: Satoshi (refined, highly legible, modern)
|
||||
- Dramatic scale hierarchy with tight tracking
|
||||
- Font sizes: clamp() for fluid responsive scaling
|
||||
|
||||
#### Color System
|
||||
- **Deeper blacks**: Surface from `#0E0E10` for true depth
|
||||
- **Electric Cyan Primary**: `#06B6D4` with atmospheric glow
|
||||
- **Vivid Magenta Secondary**: `#D946EF` for accent moments
|
||||
- **Atmospheric gradients**: Radial mesh backgrounds with subtle grain overlay
|
||||
|
||||
#### Spatial Architecture
|
||||
- **Floating panels**: Backdrop blur + subtle borders + ambient shadows
|
||||
- **Layered depth**: Multiple surface elevations with distinct blur levels
|
||||
- **Generous spacing**: 8-10 unit gaps between major sections
|
||||
- **Rounded corners**: 3xl (1.75rem) for premium feel
|
||||
|
||||
#### Motion Design
|
||||
- **Kinetic interactions**: Scale transforms on hover (1.03x)
|
||||
- **Staggered reveals**: Sequential fade-up animations with delays
|
||||
- **Smooth transitions**: 300-400ms cubic-bezier easing
|
||||
- **Ambient glows**: Pulsing shadows on interactive elements
|
||||
|
||||
#### Component Enhancements
|
||||
|
||||
**Buttons**
|
||||
- Gradient primary with overlay effects
|
||||
- Backdrop blur on secondary variants
|
||||
- Ring focus states with offset
|
||||
- Icon size increased to 18px
|
||||
|
||||
**Cards**
|
||||
- 3xl border radius (1.75rem)
|
||||
- 60% opacity backgrounds with backdrop blur
|
||||
- Border glow on hover
|
||||
- Panel shadows with primary tint
|
||||
|
||||
**Badges**
|
||||
- Increased padding (px-3 py-1.5)
|
||||
- Border accents matching variant color
|
||||
- Backdrop blur for depth
|
||||
- Bold uppercase tracking
|
||||
|
||||
**Metric Cards**
|
||||
- Gradient accent lines
|
||||
- Larger icons (20px)
|
||||
- Scale interaction on hover (1.02x)
|
||||
- Glow shadows on accent variant
|
||||
|
||||
**Media Cards**
|
||||
- Gradient mesh backgrounds
|
||||
- Larger monograms (text-6xl)
|
||||
- Thicker accent lines (1.5px)
|
||||
- Enhanced progress bars with gradient
|
||||
- Hover scale (1.03x)
|
||||
|
||||
**Sidebar**
|
||||
- Translucent background (40% opacity)
|
||||
- Backdrop blur 2xl
|
||||
- Larger nav items with rounded-2xl
|
||||
- Gradient brand icon with glow
|
||||
- Thin custom scrollbar
|
||||
|
||||
**Top Bar**
|
||||
- Sticky positioning
|
||||
- 60% opacity with 3xl backdrop blur
|
||||
- Larger typography (text-2xl)
|
||||
- Refined icon buttons
|
||||
|
||||
### Atmospheric Effects
|
||||
|
||||
**Grain Overlay**
|
||||
- Fixed position pseudo-element
|
||||
- Repeating linear gradient pattern
|
||||
- 3% opacity for texture
|
||||
- Non-interactive (pointer-events: none)
|
||||
|
||||
**Radial Gradients**
|
||||
- Body background with dual ellipses
|
||||
- Primary at top (-20% vertical)
|
||||
- Secondary at bottom (120% vertical)
|
||||
- 6-8% opacity for subtlety
|
||||
|
||||
**Glow System**
|
||||
- Primary glow: 32px blur, 50% intensity
|
||||
- Accent glow: Dual-layer (24px + 48px)
|
||||
- CSS variable controlled: `--glow-intensity`
|
||||
- Applied to buttons, cards, badges
|
||||
|
||||
### Animation System
|
||||
|
||||
**Keyframes**
|
||||
- `fade-up`: 16px translate with opacity
|
||||
- `scale-in`: 0.94 to 1.0 scale
|
||||
- `float`: Subtle vertical oscillation
|
||||
- `pulse-glow`: Shadow intensity variation
|
||||
- `gradient-shift`: Background position animation
|
||||
|
||||
**Timing**
|
||||
- Entrance: 400-600ms
|
||||
- Interaction: 300ms
|
||||
- Ambient: 3-4s infinite
|
||||
- Easing: cubic-bezier(0.16, 1, 0.3, 1)
|
||||
|
||||
**Stagger Delays**
|
||||
- `.stagger-1`: 50ms
|
||||
- `.stagger-2`: 100ms
|
||||
- `.stagger-3`: 150ms
|
||||
- `.stagger-4`: 200ms
|
||||
|
||||
### Responsive Behavior
|
||||
|
||||
**Breakpoints**
|
||||
- Mobile: < 640px
|
||||
- Tablet: 640px - 1024px
|
||||
- Desktop: > 1024px
|
||||
|
||||
**Adaptive Spacing**
|
||||
- Mobile: px-4 py-6
|
||||
- Desktop: px-6 py-8
|
||||
- Max width: 1600px
|
||||
|
||||
**Typography Scaling**
|
||||
- H1: clamp(2rem, 5vw, 3.5rem)
|
||||
- H2: clamp(1.5rem, 3vw, 2rem)
|
||||
- Fluid scaling prevents layout breaks
|
||||
|
||||
### Accessibility
|
||||
|
||||
**Focus States**
|
||||
- 2px ring with 40% opacity
|
||||
- 2px offset from element
|
||||
- Primary color for visibility
|
||||
- Keyboard navigation preserved
|
||||
|
||||
**Motion Preferences**
|
||||
- `prefers-reduced-motion` respected
|
||||
- Animations reduced to 1ms
|
||||
- Scroll behavior set to auto
|
||||
- Transitions minimized
|
||||
|
||||
**Color Contrast**
|
||||
- Text on surface: 15:1 ratio
|
||||
- Muted text: 7:1 ratio
|
||||
- Interactive elements: 4.5:1 minimum
|
||||
- WCAG AAA compliant
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
**CSS**
|
||||
- Hardware acceleration (translateZ)
|
||||
- Will-change on animated elements
|
||||
- Backdrop-filter with fallbacks
|
||||
- Minimal repaints
|
||||
|
||||
**Fonts**
|
||||
- Fontshare CDN (optimized delivery)
|
||||
- Subset loading (latin only)
|
||||
- Display swap for FOUT prevention
|
||||
- Preconnect hints
|
||||
|
||||
**Animations**
|
||||
- CSS-only where possible
|
||||
- Transform/opacity for 60fps
|
||||
- Reduced motion detection
|
||||
- Debounced interactions
|
||||
|
||||
### Browser Support
|
||||
|
||||
**Modern Browsers**
|
||||
- Chrome/Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Opera 76+
|
||||
|
||||
**Progressive Enhancement**
|
||||
- Backdrop-filter fallbacks
|
||||
- Gradient mesh alternatives
|
||||
- Animation graceful degradation
|
||||
- Core functionality preserved
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ Design system tokens updated
|
||||
✅ Typography system enhanced
|
||||
✅ Color palette refined
|
||||
✅ Animation system expanded
|
||||
✅ Button component upgraded
|
||||
✅ Card components enhanced
|
||||
✅ Badge component refined
|
||||
✅ Metric card redesigned
|
||||
✅ Media card transformed
|
||||
✅ Sidebar modernized
|
||||
✅ Top bar elevated
|
||||
✅ Dashboard page polished
|
||||
✅ App shell optimized
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Apply enhancements to remaining pages (Discover, Games, Watch Later, etc.)
|
||||
2. Create loading state animations
|
||||
3. Add empty state illustrations
|
||||
4. Implement toast notification system
|
||||
5. Design error boundary components
|
||||
6. Create onboarding flow
|
||||
7. Add keyboard shortcuts overlay
|
||||
8. Implement search command palette
|
||||
|
||||
---
|
||||
|
||||
**Design System Version**: 2.0.0
|
||||
**Last Updated**: 2026-04-06
|
||||
**Status**: Production Ready
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_ENABLE_MOCK_API=true
|
||||
ARG VITE_API_BASE_URL=
|
||||
ENV VITE_ENABLE_MOCK_API=${VITE_ENABLE_MOCK_API}
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,96 @@
|
||||
# Seen Frontend (Phase 1)
|
||||
|
||||
UI-first implementation for `/seen/`, a self-hosted media control center.
|
||||
|
||||
## Stack
|
||||
|
||||
- SolidJS + TypeScript + Vite
|
||||
- TailwindCSS
|
||||
- Vitest (unit)
|
||||
- Playwright (smoke e2e)
|
||||
- Docker + Nginx runtime
|
||||
|
||||
## Implemented in this phase
|
||||
|
||||
- App shell with sidebar + top bar
|
||||
- Theme system (`dark`, `light`, `system`) with persistent storage
|
||||
- Full route contract scaffolded:
|
||||
- `/app/dashboard`
|
||||
- `/app/discover`
|
||||
- `/app/movies`
|
||||
- `/app/shows`
|
||||
- `/app/games`
|
||||
- `/app/watch-later`
|
||||
- `/app/watched`
|
||||
- `/app/downloads`
|
||||
- `/app/calendar`
|
||||
- `/app/recommendations`
|
||||
- `/app/library`
|
||||
- `/app/collections`
|
||||
- `/app/settings`
|
||||
- `/app/admin`
|
||||
- Polished `Dashboard`, `Discover`, `Games`, and `Queue` pages
|
||||
- Unified media cards and search flows for movies, shows, and games
|
||||
- Typed mock service layer compatible with future API replacement
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` if you want to customize defaults.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required variables:
|
||||
|
||||
- `VITE_APP_NAME`
|
||||
- `VITE_ENABLE_MOCK_API` (`true` for mock mode, `false` for backend mode)
|
||||
|
||||
Optional mock controls:
|
||||
|
||||
- `VITE_MOCK_API_LATENCY_MS`
|
||||
- `VITE_MOCK_FORCE_ERROR`
|
||||
|
||||
Optional backend base URL:
|
||||
|
||||
- `VITE_API_BASE_URL` (leave empty to use same-origin `/api` with proxying)
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
`test:unit` forces mock API mode so local `.env` files do not accidentally switch the suite into live API mode.
|
||||
|
||||
Playwright smoke tests:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Docker runtime (frontend)
|
||||
|
||||
Build and run from repository root:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
App will be served at `http://localhost:8080`.
|
||||
@@ -0,0 +1,296 @@
|
||||
# BRUTALIST CINEMA - VISUAL SHOWCASE
|
||||
|
||||
## THE TRANSFORMATION
|
||||
|
||||
From generic media dashboard to **UNFORGETTABLE BRUTALIST CINEMA**.
|
||||
|
||||
---
|
||||
|
||||
## HERO MOMENT: THE DASHBOARD
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ╔═══════════════════════════════════════════════════════╗ │
|
||||
│ ║ ║ │
|
||||
│ ║ WELCOME ║ │
|
||||
│ ║ BACK ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ // YOUR MEDIA UNIVERSE ║ │
|
||||
│ ╚═══════════════════════════════════════════════════════╝ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ // CONT │ │ // RECO │ │ // WATC │ │ // GAME │ │
|
||||
│ │ 12 │ │ 87% │ │ 45 │ │ 23 │ │
|
||||
│ │ TITLES │ │ 8 PICKS │ │ QUEUED │ │ TO PLAY │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ │
|
||||
│ │
|
||||
│ ╔═══════════════════════════════════════════════════════╗ │
|
||||
│ ║ CONTINUE ║ │
|
||||
│ ║ // PICK UP WHERE YOU LEFT OFF ║ │
|
||||
│ ╠═══════════════════════════════════════════════════════╣ │
|
||||
│ ║ ║ │
|
||||
│ ║ [CARD] [CARD] [CARD] ║ │
|
||||
│ ║ ║ │
|
||||
│ ╚═══════════════════════════════════════════════════════╝ │
|
||||
│ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
▓▓▓▓ = HOT PINK SHADOWS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SIGNATURE ELEMENTS
|
||||
|
||||
### 1. MASSIVE TYPOGRAPHY
|
||||
```
|
||||
WELCOME BACK
|
||||
└─ 112px (7rem)
|
||||
└─ Uppercase
|
||||
└─ Tracking: -0.04em
|
||||
└─ Line height: 0.9
|
||||
└─ Font: Space Grotesk Bold
|
||||
```
|
||||
|
||||
### 2. HOT PINK SHADOWS
|
||||
```
|
||||
┌──────────┐
|
||||
│ BUTTON │
|
||||
└──────────┘
|
||||
▓▓▓▓▓▓▓▓▓▓ ← 8px offset, no blur, #FF006E
|
||||
```
|
||||
|
||||
### 3. BRUTAL BORDERS
|
||||
```
|
||||
╔═══════════╗ ← 4px solid borders
|
||||
║ CONTENT ║
|
||||
╚═══════════╝
|
||||
```
|
||||
|
||||
### 4. SCANLINES
|
||||
```
|
||||
───────────── ← 2px transparent
|
||||
█████████████ ← 2px white @ 3% opacity
|
||||
─────────────
|
||||
█████████████
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COLOR PALETTE
|
||||
|
||||
```
|
||||
BLACK ████ #000000 Background
|
||||
WHITE ████ #FFFFFF Foreground
|
||||
PINK ████ #FF006E Primary (THE signature)
|
||||
YELLOW ████ #FFFF00 Tertiary
|
||||
RED ████ #FF0000 Danger
|
||||
GREEN ████ #00FF00 Success
|
||||
GRAY ████ #8C8C8C Muted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMPONENT GALLERY
|
||||
|
||||
### Button States
|
||||
```
|
||||
DEFAULT:
|
||||
┌──────────┐
|
||||
│ BUTTON │
|
||||
└──────────┘
|
||||
▓▓▓▓▓▓▓▓
|
||||
|
||||
HOVER:
|
||||
┌──────────┐
|
||||
│ BUTTON │
|
||||
└──────────┘
|
||||
▓▓▓▓▓▓▓▓▓▓
|
||||
|
||||
ACTIVE:
|
||||
┌──────────┐
|
||||
│ BUTTON │
|
||||
└──────────┘
|
||||
▓▓▓▓
|
||||
```
|
||||
|
||||
### Media Card
|
||||
```
|
||||
╔═══════════════════╗
|
||||
║ ▌ ║ ← 2px pink accent
|
||||
║ ▌ M ║ ← Massive monogram
|
||||
║ ▌ ║
|
||||
║ ▌ [MOVIE] 2024 ║ ← Badges
|
||||
╠═══════════════════╣ ← 4px border
|
||||
║ TITLE ║
|
||||
║ Description... ║
|
||||
║ ───────────────── ║
|
||||
║ 2h 15m 8.5★ ║
|
||||
╚═══════════════════╝
|
||||
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||
```
|
||||
|
||||
### Metric Card
|
||||
```
|
||||
╔═══════════════╗
|
||||
║ ▬▬▬▬ ║ ← 1px accent line
|
||||
║ ║
|
||||
║ // LABEL ║ ← Mono font
|
||||
║ ║
|
||||
║ 87 ║ ← Massive number
|
||||
║ ║
|
||||
║ DETAIL [▣] ║ ← Icon in box
|
||||
╚═══════════════╝
|
||||
▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||
```
|
||||
|
||||
### Navigation Item (Active)
|
||||
```
|
||||
╔═══════════════════╗
|
||||
║ ╔═╗ DASHBOARD ║
|
||||
║ ║▣║ ║
|
||||
║ ╚═╝ ║
|
||||
╚═══════════════════╝
|
||||
▓▓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TYPOGRAPHY HIERARCHY
|
||||
|
||||
```
|
||||
H1 ████████████████████ 112px WELCOME BACK
|
||||
H2 ████████████ 64px CONTINUE
|
||||
H3 ████████ 40px Section Title
|
||||
P ████ 14px Body Text
|
||||
SM ███ 12px Meta Info
|
||||
XS ██ 10px Labels
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ANIMATION SHOWCASE
|
||||
|
||||
### Brutal Appear (steps)
|
||||
```
|
||||
Frame 1: ░░░░░░░░ (opacity: 0)
|
||||
Frame 2: ████████ (opacity: 1)
|
||||
```
|
||||
|
||||
### Brutal Scale (steps)
|
||||
```
|
||||
Frame 1: ▪▪▪▪▪▪ (scale: 0.9)
|
||||
Frame 2: ████████ (scale: 1.05)
|
||||
Frame 3: ██████ (scale: 1.0)
|
||||
```
|
||||
|
||||
### Glitch Effect
|
||||
```
|
||||
Normal: TITLE
|
||||
Glitch: T̴I̵T̶L̷E̸
|
||||
▓▓▓▓▓ ← Red/yellow chromatic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LAYOUT STRUCTURE
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SIDEBAR │ TOP BAR │
|
||||
│ ═════════ │ ═════════════════════════════════│
|
||||
│ │ │
|
||||
│ ╔═══════╗ │ CONTENT AREA │
|
||||
│ ║ S ║ │ │
|
||||
│ ╚═══════╝ │ ┌──────────┐ ┌──────────┐ │
|
||||
│ SEEN │ │ CARD │ │ CARD │ │
|
||||
│ │ └──────────┘ └──────────┘ │
|
||||
│ ╔═══════════╗ │ ▓▓▓▓ ▓▓▓▓ │
|
||||
│ ║ ╔═╗ DASH ║ │ │
|
||||
│ ╚═══════════╝ │ ╔═══════════════════════════╗ │
|
||||
│ ▓▓ │ ║ SECTION ║ │
|
||||
│ │ ╚═══════════════════════════╝ │
|
||||
│ ╔═══════════╗ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||
│ ║ ╔═╗ DISC ║ │ │
|
||||
│ ╚═══════════╝ │ │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## INTERACTION PATTERNS
|
||||
|
||||
### Hover Transformation
|
||||
```
|
||||
BEFORE: AFTER:
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ ELEMENT │ → │ ELEMENT │
|
||||
└──────────┘ └──────────┘
|
||||
▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓
|
||||
```
|
||||
|
||||
### Click Feedback
|
||||
```
|
||||
HOVER: ACTIVE:
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ BUTTON │ → │ BUTTON │
|
||||
└──────────┘ └──────────┘
|
||||
▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VISUAL EFFECTS
|
||||
|
||||
### Noise Texture
|
||||
```
|
||||
████████████████████
|
||||
█▓▒░▓█▒░▓█░▒▓█▓░▒█▓░ ← Fractal noise
|
||||
████████████████████ 8% opacity
|
||||
█░▒▓█▓░▒█▒░▓█░▒▓█▒░▓ Overlay blend
|
||||
████████████████████
|
||||
```
|
||||
|
||||
### Scanlines
|
||||
```
|
||||
───────────────────── ← Transparent
|
||||
█████████████████████ ← White 3%
|
||||
─────────────────────
|
||||
█████████████████████
|
||||
─────────────────────
|
||||
```
|
||||
|
||||
### Diagonal Stripes
|
||||
```
|
||||
▓▓▓▓
|
||||
▓▓▓▓
|
||||
▓▓▓▓ ← 45deg
|
||||
▓▓▓▓ Pink 5%
|
||||
▓▓▓▓ 20px repeat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## THE UNFORGETTABLE MOMENT
|
||||
|
||||
When you open /seen/, you see:
|
||||
|
||||
1. **PURE BLACK** background
|
||||
2. **MASSIVE WHITE** typography screaming "WELCOME BACK"
|
||||
3. **HOT PINK SHADOWS** on every interactive element
|
||||
4. **HARD EDGES** everywhere - zero curves
|
||||
5. **SCANLINES** creating retro-tech atmosphere
|
||||
6. **INSTANT ANIMATIONS** - no smooth transitions
|
||||
7. **BRUTAL BORDERS** - thick, aggressive, unapologetic
|
||||
|
||||
This is not a dashboard.
|
||||
|
||||
This is **BRUTALIST CINEMA**.
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The goal isn't to be pretty. The goal is to be **UNFORGETTABLE**.
|
||||
|
||||
Mission accomplished. ▓▓▓▓
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 493 KiB |
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Seen is a modern self-hosted control center for movies and TV shows."
|
||||
/>
|
||||
<meta name="theme-color" content="#0f1114" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Seen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://api.fontshare.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.fontshare.com; img-src 'self' data: https:; connect-src 'self' http: https: ws: wss:; frame-ancestors 'self';" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://seen-backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
Generated
+5025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "seen-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:unit",
|
||||
"test:unit": "VITE_ENABLE_MOCK_API=true VITE_MOCK_FORCE_ERROR=false vitest run",
|
||||
"test:unit:watch": "VITE_ENABLE_MOCK_API=true VITE_MOCK_FORCE_ERROR=false vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ark-ui/solid": "^5.34.1",
|
||||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-solid": "^0.577.0",
|
||||
"solid-js": "^1.9.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@solidjs/testing-library": "^0.8.10",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
timeout: 60_000,
|
||||
workers: 1,
|
||||
expect: {
|
||||
timeout: 7_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4213',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'VITE_ENABLE_MOCK_API=true VITE_MOCK_API_LATENCY_MS=450 npm run dev -- --host 127.0.0.1 --port 4213',
|
||||
url: 'http://127.0.0.1:4213',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="18" fill="#081523" />
|
||||
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#surface)" />
|
||||
<path
|
||||
d="M22 18.5h17.2c8.4 0 13.3 4.1 13.3 10.7 0 4.8-2.7 8.4-7.4 9.8 5.8 1.3 9.1 5.2 9.1 10.6 0 8.2-5.8 13-15.6 13H22v-8.8h15.5c4.4 0 7-1.8 7-4.9 0-3.1-2.6-4.8-7-4.8H26.8v-8.3h10.1c4.1 0 6.4-1.6 6.4-4.4 0-2.7-2.2-4.2-6.1-4.2H22v-8.6Z"
|
||||
fill="#F3F6FA"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="6" y1="8" x2="58" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#123249" />
|
||||
<stop offset="0.52" stop-color="#0A1731" />
|
||||
<stop offset="1" stop-color="#1C365B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 747 B |
@@ -0,0 +1,16 @@
|
||||
import { Router, type RouteSectionProps } from '@solidjs/router'
|
||||
import { AppRoutes } from '@/app/routes'
|
||||
import { AuthProvider } from '@/stores/auth-store'
|
||||
import { ThemeProvider } from '@/stores/theme-store'
|
||||
|
||||
const RootProviders = (props: RouteSectionProps) => (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>{props.children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
export const App = () => (
|
||||
<Router root={RootProviders}>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Compass,
|
||||
Home,
|
||||
Library,
|
||||
Settings,
|
||||
Shield,
|
||||
} from 'lucide-solid'
|
||||
import type { Component } from 'solid-js'
|
||||
|
||||
type IconComponent = Component<{ class?: string; size?: number | string }>
|
||||
|
||||
export interface AppNavRoute {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
icon: IconComponent
|
||||
implemented: boolean
|
||||
}
|
||||
|
||||
export const appNavRoutes: AppNavRoute[] = [
|
||||
{
|
||||
path: '/app/dashboard',
|
||||
label: 'Home',
|
||||
description: 'Your media overview and activity.',
|
||||
icon: Home,
|
||||
implemented: true,
|
||||
},
|
||||
{
|
||||
path: '/app/discover',
|
||||
label: 'Discover',
|
||||
description: 'Browse and search the catalog.',
|
||||
icon: Compass,
|
||||
implemented: true,
|
||||
},
|
||||
{
|
||||
path: '/app/library',
|
||||
label: 'Library',
|
||||
description: 'Your collection, queue, and history.',
|
||||
icon: Library,
|
||||
implemented: true,
|
||||
},
|
||||
{
|
||||
path: '/app/settings',
|
||||
label: 'Settings',
|
||||
description: 'Profile, playback, and integrations.',
|
||||
icon: Settings,
|
||||
implemented: true,
|
||||
},
|
||||
{
|
||||
path: '/app/admin',
|
||||
label: 'Admin',
|
||||
description: 'System controls and diagnostics.',
|
||||
icon: Shield,
|
||||
implemented: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const routeByPath = (path: string): AppNavRoute | undefined =>
|
||||
appNavRoutes.find((route) => route.path === path)
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Navigate, Route, useLocation, type RouteSectionProps } from '@solidjs/router'
|
||||
import { Show } from 'solid-js'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
import { AdminPage } from '@/pages/admin-page'
|
||||
import { CalendarPage } from '@/pages/calendar-page'
|
||||
import { CollectionsPage } from '@/pages/collections-page'
|
||||
import { DashboardPage } from '@/pages/dashboard-page'
|
||||
import { DiscoverPage } from '@/pages/discover-page'
|
||||
import { DownloadsPage } from '@/pages/downloads-page'
|
||||
import { GamesPage } from '@/pages/games-page'
|
||||
import { LibraryPage } from '@/pages/library-page'
|
||||
import { LoginPage } from '@/pages/login-page'
|
||||
import { MoviesPage } from '@/pages/movies-page'
|
||||
import { RecommendationsPage } from '@/pages/recommendations-page'
|
||||
import { SettingsPage } from '@/pages/settings-page'
|
||||
import { ShowsPage } from '@/pages/shows-page'
|
||||
import { WatchLaterPage } from '@/pages/watch-later-page'
|
||||
import { WatchedPage } from '@/pages/watched-page'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
|
||||
const resolveRedirectPath = (search: string): string => {
|
||||
const redirect = new URLSearchParams(search).get('redirect')
|
||||
|
||||
if (!redirect || !redirect.startsWith('/app/')) {
|
||||
return '/app/dashboard'
|
||||
}
|
||||
|
||||
return redirect
|
||||
}
|
||||
|
||||
const SessionGate = (props: RouteSectionProps) => {
|
||||
const auth = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!auth.isInitializing()}
|
||||
fallback={
|
||||
<div class="grid min-h-screen place-items-center">
|
||||
<p class="text-sm text-muted-fg">Preparing your session...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={auth.isAuthenticated()}
|
||||
fallback={<Navigate href={`/login?redirect=${encodeURIComponent(`${location.pathname}${location.search}`)}`} />}
|
||||
>
|
||||
<AppShell>{props.children}</AppShell>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginGate = () => {
|
||||
const auth = useAuth()
|
||||
const redirectSearch = typeof window === 'undefined' ? '' : window.location.search
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!auth.isInitializing()}
|
||||
fallback={
|
||||
<div class="grid min-h-screen place-items-center">
|
||||
<p class="text-sm text-muted-fg">Preparing your session...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={!auth.isAuthenticated()} fallback={<Navigate href={resolveRedirectPath(redirectSearch)} />}>
|
||||
<LoginPage />
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const RootRedirect = () => {
|
||||
const auth = useAuth()
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!auth.isInitializing()}
|
||||
fallback={
|
||||
<div class="grid min-h-screen place-items-center">
|
||||
<p class="text-sm text-muted-fg">Preparing your session...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Navigate href={auth.isAuthenticated() ? '/app/dashboard' : '/login'} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const AppRoutes = () => (
|
||||
<>
|
||||
<Route path="/" component={RootRedirect} />
|
||||
<Route path="/login" component={LoginGate} />
|
||||
|
||||
<Route path="/app" component={SessionGate}>
|
||||
<Route path="/" component={() => <Navigate href="/app/dashboard" />} />
|
||||
<Route path="/dashboard" component={DashboardPage} />
|
||||
<Route path="/discover" component={DiscoverPage} />
|
||||
<Route path="/movies" component={MoviesPage} />
|
||||
<Route path="/shows" component={ShowsPage} />
|
||||
<Route path="/games" component={GamesPage} />
|
||||
<Route path="/watch-later" component={WatchLaterPage} />
|
||||
<Route path="/watched" component={WatchedPage} />
|
||||
<Route path="/downloads" component={DownloadsPage} />
|
||||
<Route path="/calendar" component={CalendarPage} />
|
||||
<Route path="/recommendations" component={RecommendationsPage} />
|
||||
<Route path="/library" component={LibraryPage} />
|
||||
<Route path="/collections" component={CollectionsPage} />
|
||||
<Route path="/settings" component={SettingsPage} />
|
||||
<Route path="/admin" component={AdminPage} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" component={RootRedirect} />
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useLocation } from '@solidjs/router'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { createMemo, createSignal } from 'solid-js'
|
||||
import { routeByPath } from '@/app/navigation'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
import { TopBar } from '@/components/layout/top-bar'
|
||||
|
||||
export const AppShell = (props: { children?: JSX.Element }) => {
|
||||
const location = useLocation()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false)
|
||||
|
||||
const currentRoute = createMemo(
|
||||
() => routeByPath(location.pathname) ?? routeByPath('/app/dashboard'),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="grid min-h-screen lg:grid-cols-[16rem_1fr] bg-surface scanlines" data-testid="app-shell">
|
||||
<Sidebar mobileOpen={isSidebarOpen()} onCloseMobile={() => setIsSidebarOpen(false)} />
|
||||
|
||||
<div class="relative flex min-w-0 flex-col">
|
||||
<TopBar
|
||||
pageTitle={currentRoute()?.label ?? 'SEEN'}
|
||||
pageDescription={currentRoute()?.description ?? 'YOUR PERSONAL MEDIA CONTROL CENTER'}
|
||||
onToggleSidebar={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<main class="flex-1 px-6 py-8 sm:px-8 sm:py-10">
|
||||
<div class="mx-auto w-full max-w-[1800px]">
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { A, useLocation } from '@solidjs/router'
|
||||
import { For, Show } from 'solid-js'
|
||||
import { appNavRoutes } from '@/app/navigation'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean
|
||||
onCloseMobile: () => void
|
||||
}
|
||||
|
||||
const SidebarContent = (props: { onNavigate?: () => void }) => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div class="flex h-full flex-col p-6 scanlines">
|
||||
{/* BRUTAL BRAND */}
|
||||
<div class="flex items-center gap-4 pb-8 border-b-4 border-primary">
|
||||
<div class="relative grid h-14 w-14 place-items-center bg-primary border-4 border-fg shadow-brutal-sm">
|
||||
<span class="font-display text-2xl font-bold text-on-primary">S</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-display text-2xl font-bold leading-none text-fg uppercase tracking-tight">SEEN</p>
|
||||
<p class="mt-1 text-[9px] font-bold uppercase tracking-[0.2em] text-primary font-mono">CTRL CENTER</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NAVIGATION */}
|
||||
<nav class="flex-1 overflow-y-auto scrollbar-brutal pt-8" aria-label="Primary navigation">
|
||||
<p class="px-2 text-[9px] font-bold uppercase tracking-[0.2em] text-muted-fg font-mono mb-4">// NAVIGATE</p>
|
||||
<ul class="space-y-2">
|
||||
<For each={appNavRoutes}>
|
||||
{(route) => {
|
||||
const isActive = () => location.pathname === route.path
|
||||
const Icon = route.icon
|
||||
|
||||
return (
|
||||
<li>
|
||||
<A
|
||||
href={route.path}
|
||||
class={cn(
|
||||
'group relative flex items-center gap-4 px-4 py-3 text-sm font-bold uppercase tracking-wide transition-all duration-100 border-3',
|
||||
isActive()
|
||||
? 'bg-primary text-on-primary border-primary shadow-brutal-sm translate-x-[-4px]'
|
||||
: 'bg-transparent text-fg border-outline hover:border-fg hover:translate-x-[-2px]',
|
||||
)}
|
||||
data-route={route.path}
|
||||
onClick={props.onNavigate}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
'flex h-10 w-10 items-center justify-center border-2 transition-all duration-100',
|
||||
isActive()
|
||||
? 'bg-on-primary text-primary border-on-primary'
|
||||
: 'bg-surface-container text-fg border-outline group-hover:border-fg',
|
||||
)}
|
||||
>
|
||||
<Icon class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-bold text-xs tracking-wider">{route.label}</p>
|
||||
</div>
|
||||
<Show when={!route.implemented}>
|
||||
<span class="bg-surface-high px-2 py-1 text-[8px] font-bold uppercase tracking-[0.15em] text-muted-fg border-2 border-outline">
|
||||
SOON
|
||||
</span>
|
||||
</Show>
|
||||
</A>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div class="pt-6 border-t-4 border-outline">
|
||||
<div class="bg-surface-container px-4 py-3 border-3 border-outline">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-muted-fg tracking-tight font-mono">v0.1.0</span>
|
||||
<span class="text-[9px] font-bold text-primary uppercase tracking-wider font-mono">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar = (props: SidebarProps) => (
|
||||
<>
|
||||
{/* Desktop Sidebar */}
|
||||
<aside class="hidden h-full w-64 bg-surface border-r-4 border-outline lg:block">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<div
|
||||
class={cn(
|
||||
'fixed inset-0 z-40 bg-black/90 transition-opacity duration-100 lg:hidden',
|
||||
props.mobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
onClick={props.onCloseMobile}
|
||||
>
|
||||
<aside
|
||||
class={cn(
|
||||
'h-full w-64 bg-surface transition-transform duration-100 border-r-4 border-primary',
|
||||
props.mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<SidebarContent onNavigate={props.onCloseMobile} />
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { LogOut, Moon, PanelLeft, Sun } from 'lucide-solid'
|
||||
import { Show } from 'solid-js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import { useTheme } from '@/stores/theme-store'
|
||||
|
||||
interface TopBarProps {
|
||||
pageTitle: string
|
||||
pageDescription: string
|
||||
onToggleSidebar: () => void
|
||||
}
|
||||
|
||||
export const TopBar = (props: TopBarProps) => {
|
||||
const theme = useTheme()
|
||||
const auth = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const logout = (): void => {
|
||||
auth.logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const toggleTheme = (): void => {
|
||||
const modes = ['dark', 'light', 'system'] as const
|
||||
const currentIndex = modes.indexOf(theme.mode())
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
theme.setMode(modes[nextIndex])
|
||||
}
|
||||
|
||||
return (
|
||||
<header class="bg-surface border-b-4 border-primary sticky top-0 z-30 scanlines">
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4">
|
||||
<div class="flex items-center gap-4 min-w-0 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="lg:hidden shrink-0"
|
||||
aria-label="Open navigation"
|
||||
onClick={props.onToggleSidebar}
|
||||
>
|
||||
<PanelLeft class="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-fg truncate uppercase tracking-tighter">{props.pageTitle}</h1>
|
||||
<p class="text-xs sm:text-sm text-muted-fg mt-1 truncate uppercase tracking-wider font-mono">{props.pageDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={theme.mode() === 'dark'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Switch to light mode"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Moon class="h-5 w-5" />
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={theme.mode() === 'light'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Switch to system mode"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Sun class="h-5 w-5" />
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={theme.mode() === 'system'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Switch to dark mode"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Sun class="h-5 w-5" />
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
size="icon"
|
||||
aria-label="Logout"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Clock, Download, Pause, Play, Trash2, X } from 'lucide-solid'
|
||||
import { Show, splitProps, type JSX } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CircularProgress, Progress } from '@/components/ui/progress'
|
||||
import type { DownloadJob } from '@/types/domain'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface DownloadCardProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
job: DownloadJob
|
||||
onCancel?: () => void
|
||||
onPause?: () => void
|
||||
onResume?: () => void
|
||||
}
|
||||
|
||||
const statusConfig: Record<DownloadJob['status'], { label: string; variant: 'neutral' | 'accent' | 'success' | 'warning' | 'danger' }> = {
|
||||
queued: { label: 'Queued', variant: 'neutral' },
|
||||
downloading: { label: 'Downloading', variant: 'accent' },
|
||||
stalled: { label: 'Stalled', variant: 'warning' },
|
||||
completed: { label: 'Completed', variant: 'success' },
|
||||
failed: { label: 'Failed', variant: 'danger' },
|
||||
}
|
||||
|
||||
const formatSpeed = (mbps: number): string => {
|
||||
if (mbps >= 100) return `${mbps.toFixed(0)} MB/s`
|
||||
if (mbps >= 10) return `${mbps.toFixed(1)} MB/s`
|
||||
return `${mbps.toFixed(2)} MB/s`
|
||||
}
|
||||
|
||||
const formatEta = (minutes: number): string => {
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export const DownloadCard = (props: DownloadCardProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'job', 'onCancel', 'onPause', 'onResume'])
|
||||
|
||||
const config = () => statusConfig[local.job.status]
|
||||
const isActive = () => ['downloading', 'queued'].includes(local.job.status)
|
||||
const isStalled = () => local.job.status === 'stalled'
|
||||
const isCompleted = () => local.job.status === 'completed'
|
||||
const isFailed = () => local.job.status === 'failed'
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-2xl bg-surface-container overflow-hidden',
|
||||
'border border-outline-variant/15',
|
||||
'transition-all duration-300',
|
||||
'hover:bg-surface-high hover:shadow-ambient',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div class="p-5 space-y-4">
|
||||
{/* Header */}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-fg line-clamp-1">{local.job.title}</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={config().variant}>{config().label}</Badge>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">
|
||||
{local.job.sourceType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<Show when={isActive() || isStalled()}>
|
||||
<CircularProgress
|
||||
value={local.job.progressPercent}
|
||||
size={44}
|
||||
strokeWidth={3}
|
||||
variant={isStalled() ? 'warning' : 'accent'}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={isCompleted()}>
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-full bg-success/20">
|
||||
<Download class="h-5 w-5 text-success" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isFailed()}>
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-full bg-danger/20">
|
||||
<X class="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Show when={isActive() || isStalled()}>
|
||||
<Progress
|
||||
value={local.job.progressPercent}
|
||||
variant={isStalled() ? 'warning' : 'accent'}
|
||||
size="sm"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Stats */}
|
||||
<Show when={isActive()}>
|
||||
<div class="flex items-center gap-6 text-xs text-muted-fg">
|
||||
<Show when={local.job.downloadSpeedMbps > 0}>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{formatSpeed(local.job.downloadSpeedMbps)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={local.job.etaMinutes > 0}>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Clock class="h-3.5 w-3.5" />
|
||||
{formatEta(local.job.etaMinutes)} left
|
||||
</span>
|
||||
</Show>
|
||||
<span class="ml-auto font-semibold text-fg tabular-nums">
|
||||
{local.job.progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Stalled Warning */}
|
||||
<Show when={isStalled()}>
|
||||
<div class="rounded-lg bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Download stalled. Check connection or try restarting.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Failed Error */}
|
||||
<Show when={isFailed()}>
|
||||
<div class="rounded-lg bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
Download failed. Please try again or check the source.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Actions */}
|
||||
<Show when={local.onCancel || local.onPause || local.onResume}>
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<Show when={isActive() && local.onPause}>
|
||||
{(onPause) => (
|
||||
<Button variant="ghost" size="sm" onClick={onPause()}>
|
||||
<Pause class="h-3.5 w-3.5" />
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={local.job.status === 'queued' && local.onResume}>
|
||||
{(onResume) => (
|
||||
<Button variant="ghost" size="sm" onClick={onResume()}>
|
||||
<Play class="h-3.5 w-3.5" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={local.onCancel}>
|
||||
{(onCancel) => (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel()} class="text-danger hover:text-danger hover:bg-danger/10">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Carousel as ArkCarousel } from '@ark-ui/solid/carousel'
|
||||
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-solid'
|
||||
import { Index, Show, splitProps, type JSX } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { formatRating, formatRuntime } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaMonogram, mediaTypeLabel, mediaYear } from '@/utils/media'
|
||||
|
||||
export interface HeroCarouselProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
items: MediaItem[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
}
|
||||
|
||||
export const HeroCarousel = (props: HeroCarouselProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'items', 'autoplay', 'loop'])
|
||||
|
||||
return (
|
||||
<ArkCarousel.Root
|
||||
class={cn('relative w-full', local.class)}
|
||||
slideCount={local.items.length}
|
||||
autoplay={local.autoplay ? { delay: 6000 } : false}
|
||||
loop={local.loop ?? true}
|
||||
{...rest}
|
||||
>
|
||||
<ArkCarousel.ItemGroup
|
||||
class={cn(
|
||||
'flex overflow-hidden rounded-2xl',
|
||||
'scrollbar-hide',
|
||||
)}
|
||||
>
|
||||
<Index each={local.items}>
|
||||
{(item, index) => (
|
||||
<ArkCarousel.Item
|
||||
class={cn(
|
||||
'flex-shrink-0 flex-grow-0 basis-full',
|
||||
'focus:outline-none',
|
||||
)}
|
||||
index={index}
|
||||
>
|
||||
<HeroSlide item={item()} />
|
||||
</ArkCarousel.Item>
|
||||
)}
|
||||
</Index>
|
||||
</ArkCarousel.ItemGroup>
|
||||
|
||||
{/* Overlay Controls */}
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 via-black/40 to-transparent">
|
||||
<ArkCarousel.Control class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ArkCarousel.PrevTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-white/10 text-white backdrop-blur-sm',
|
||||
'hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</ArkCarousel.PrevTrigger>
|
||||
|
||||
<Show when={local.autoplay}>
|
||||
<ArkCarousel.AutoplayTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-white/10 text-white backdrop-blur-sm',
|
||||
'hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<ArkCarousel.AutoplayIndicator fallback={<Play class="h-4 w-4" />}>
|
||||
<Pause class="h-4 w-4" />
|
||||
</ArkCarousel.AutoplayIndicator>
|
||||
</ArkCarousel.AutoplayTrigger>
|
||||
</Show>
|
||||
|
||||
<ArkCarousel.NextTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-white/10 text-white backdrop-blur-sm',
|
||||
'hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
)}
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</ArkCarousel.NextTrigger>
|
||||
</div>
|
||||
|
||||
<ArkCarousel.IndicatorGroup class="flex items-center gap-1.5">
|
||||
<Index each={local.items}>
|
||||
{(_, index) => (
|
||||
<ArkCarousel.Indicator
|
||||
class={cn(
|
||||
'h-1.5 w-1.5 rounded-full bg-white/40',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-white/60',
|
||||
'data-[current]:bg-primary data-[current]:w-4',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
)}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</Index>
|
||||
</ArkCarousel.IndicatorGroup>
|
||||
</ArkCarousel.Control>
|
||||
</div>
|
||||
</ArkCarousel.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const HeroSlide = (props: { item: MediaItem }) => {
|
||||
const tone = () => mediaBadgeVariant(props.item.type)
|
||||
|
||||
return (
|
||||
<div class="relative h-64 sm:h-80 lg:h-96 overflow-hidden">
|
||||
{/* Background Gradient */}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/30 via-surface-container to-secondary/20" />
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div class="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-primary/10 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 w-1/3 h-1/2 bg-gradient-to-tr from-secondary/10 to-transparent" />
|
||||
|
||||
{/* Monogram Background */}
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-10">
|
||||
<p class="font-display text-[12rem] font-bold text-fg/30">
|
||||
{mediaMonogram(props.item.title)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="absolute inset-0 flex flex-col justify-end p-6 sm:p-8">
|
||||
<div class="max-w-xl space-y-4">
|
||||
{/* Badges */}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={tone()} class="flex items-center gap-1.5 bg-white/20 backdrop-blur-sm">
|
||||
{mediaTypeLabel(props.item.type)}
|
||||
</Badge>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/70">
|
||||
{mediaYear(props.item.releaseDate)}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-tertiary">
|
||||
{formatRating(props.item.rating)} ★
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 class="text-2xl sm:text-3xl lg:text-4xl font-display font-bold text-white leading-tight">
|
||||
{props.item.title}
|
||||
</h3>
|
||||
|
||||
{/* Overview */}
|
||||
<p class="text-sm sm:text-base text-white/80 line-clamp-2 max-w-lg leading-relaxed">
|
||||
{props.item.overview}
|
||||
</p>
|
||||
|
||||
{/* Meta */}
|
||||
<div class="flex items-center gap-4 text-sm text-white/60">
|
||||
<Show when={props.item.type !== 'game'}>
|
||||
<span>{formatRuntime(props.item.runtimeMinutes, props.item.type)}</span>
|
||||
</Show>
|
||||
<Show when={props.item.genres.length > 0}>
|
||||
<span>{props.item.genres.slice(0, 3).join(' • ')}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Button variant="primary" size="md" class="shadow-glow">
|
||||
View Details
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" class="bg-white/10 backdrop-blur-sm hover:bg-white/20 text-white border-white/20">
|
||||
+ Watchlist
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Show, splitProps, type JSX } from 'solid-js'
|
||||
import { MediaDetailDialog } from '@/components/media/media-detail-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { formatRating, formatRuntime } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaMeta, mediaMonogram, mediaTypeLabel, mediaYear } from '@/utils/media'
|
||||
|
||||
interface MediaCardProps extends JSX.HTMLAttributes<HTMLElement> {
|
||||
item: MediaItem
|
||||
progressPercent?: number
|
||||
subtitle?: string
|
||||
onClick?: () => void
|
||||
showDialog?: boolean
|
||||
}
|
||||
|
||||
export const MediaCard = (props: MediaCardProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'item', 'progressPercent', 'subtitle', 'onClick', 'showDialog'])
|
||||
const tone = () => mediaBadgeVariant(local.item.type)
|
||||
|
||||
const cardContent = (
|
||||
<article
|
||||
class={cn(
|
||||
'group bg-surface-container overflow-hidden transition-all duration-100 hover:translate-x-[-4px] hover:translate-y-[-4px] cursor-pointer border-4 border-outline shadow-brutal-outline hover:shadow-brutal',
|
||||
local.class,
|
||||
)}
|
||||
onClick={local.onClick}
|
||||
{...rest}
|
||||
>
|
||||
<div class="relative h-56 overflow-hidden bg-brutal-stripes">
|
||||
{/* Monogram */}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-surface-low">
|
||||
<p class="font-display text-8xl font-bold text-fg/5 tracking-tighter uppercase">
|
||||
{mediaMonogram(local.item.title)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Accent Line */}
|
||||
<div class="absolute left-0 top-0 bottom-0 w-2 bg-primary" />
|
||||
|
||||
{/* Badges */}
|
||||
<div class="absolute right-4 top-4 flex items-center gap-2">
|
||||
<Badge variant={tone()}>{mediaTypeLabel(local.item.type)}</Badge>
|
||||
<span class="text-[9px] font-bold uppercase tracking-[0.15em] text-fg bg-surface px-3 py-1.5 border-2 border-outline font-mono">
|
||||
{mediaYear(local.item.releaseDate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Show when={typeof local.progressPercent === 'number'}>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-2 bg-surface-highest border-t-2 border-outline">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-100"
|
||||
style={{ width: `${Math.min(100, Math.max(0, local.progressPercent ?? 0))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-3 border-t-4 border-outline">
|
||||
<h4 class="line-clamp-1 text-base font-bold text-fg tracking-tight uppercase group-hover:text-primary transition-colors">{local.item.title}</h4>
|
||||
<p class="line-clamp-2 min-h-[2.5rem] text-xs text-muted-fg leading-relaxed">
|
||||
{local.subtitle ?? local.item.overview}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-fg pt-2 border-t-2 border-outline">
|
||||
<span class="font-bold uppercase tracking-wide font-mono">{mediaMeta(local.item) || formatRuntime(local.item.runtimeMinutes, local.item.type)}</span>
|
||||
<span class="font-bold text-tertiary flex items-center gap-1 font-mono">
|
||||
{formatRating(local.item.rating)} <span class="text-tertiary">★</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={local.showDialog !== false} fallback={cardContent}>
|
||||
<MediaDetailDialog item={local.item} progressPercent={local.progressPercent}>
|
||||
{cardContent}
|
||||
</MediaDetailDialog>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Calendar, Clock, Eye, Film, Gamepad2, Plus, Star, Tv, X } from 'lucide-solid'
|
||||
import { Dialog as ArkDialog } from '@ark-ui/solid/dialog'
|
||||
import { Portal } from 'solid-js/web'
|
||||
import { Show, splitProps, type ParentComponent } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { formatRating, formatRuntime } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaMeta, mediaMonogram, mediaTypeLabel, mediaYear } from '@/utils/media'
|
||||
|
||||
export interface MediaDetailDialogProps {
|
||||
item: MediaItem
|
||||
progressPercent?: number
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onAddToWatchlist?: () => void
|
||||
onMarkWatched?: () => void
|
||||
onContinueWatching?: () => void
|
||||
}
|
||||
|
||||
const MediaTypeIcon = (props: { type: MediaItem['type']; class?: string }) => {
|
||||
const icons = {
|
||||
movie: Film,
|
||||
show: Tv,
|
||||
game: Gamepad2,
|
||||
}
|
||||
const Icon = icons[props.type]
|
||||
return <Icon class={cn('h-4 w-4', props.class)} />
|
||||
}
|
||||
|
||||
export const MediaDetailDialog: ParentComponent<MediaDetailDialogProps> = (props) => {
|
||||
const [local] = splitProps(props, [
|
||||
'item',
|
||||
'progressPercent',
|
||||
'open',
|
||||
'onOpenChange',
|
||||
'onAddToWatchlist',
|
||||
'onMarkWatched',
|
||||
'onContinueWatching',
|
||||
'children',
|
||||
])
|
||||
|
||||
const tone = () => mediaBadgeVariant(local.item.type)
|
||||
|
||||
return (
|
||||
<ArkDialog.Root open={local.open} onOpenChange={(details) => local.onOpenChange?.(details.open)}>
|
||||
<ArkDialog.Trigger class="w-full text-left">
|
||||
<Show when={local.children} fallback={<MediaDetailTrigger item={local.item} progressPercent={local.progressPercent} />}>
|
||||
{local.children}
|
||||
</Show>
|
||||
</ArkDialog.Trigger>
|
||||
|
||||
<Portal>
|
||||
<ArkDialog.Backdrop
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
||||
)}
|
||||
/>
|
||||
<ArkDialog.Positioner class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<ArkDialog.Content
|
||||
class={cn(
|
||||
'relative w-full max-w-2xl max-h-[85vh] overflow-hidden',
|
||||
'rounded-2xl bg-surface-container shadow-panel',
|
||||
'border border-outline-variant/15',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
||||
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
||||
'focus:outline-none',
|
||||
)}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<div class="relative h-48 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-surface-container to-secondary/10" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-surface-container via-transparent to-transparent" />
|
||||
|
||||
{/* Monogram */}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<p class="font-display text-8xl font-bold text-fg/5">
|
||||
{mediaMonogram(local.item.title)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<ArkDialog.CloseTrigger
|
||||
class={cn(
|
||||
'absolute right-4 top-4 z-10',
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
'bg-surface-high text-muted-fg',
|
||||
'hover:bg-surface-highest hover:text-fg',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
)}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</ArkDialog.CloseTrigger>
|
||||
|
||||
{/* Type badge */}
|
||||
<div class="absolute left-6 bottom-6 flex items-center gap-3">
|
||||
<Badge variant={tone()} class="flex items-center gap-1.5">
|
||||
<MediaTypeIcon type={local.item.type} />
|
||||
{mediaTypeLabel(local.item.type)}
|
||||
</Badge>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-muted-fg">
|
||||
{mediaYear(local.item.releaseDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div class="flex flex-col space-y-1.5 px-6 pt-6 pb-0">
|
||||
<ArkDialog.Title class="text-2xl font-semibold text-fg pr-8">
|
||||
{local.item.title}
|
||||
</ArkDialog.Title>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-fg">
|
||||
<Show when={local.item.type !== 'game'}>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Clock class="h-3.5 w-3.5" />
|
||||
{formatRuntime(local.item.runtimeMinutes, local.item.type)}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Calendar class="h-3.5 w-3.5" />
|
||||
{new Date(local.item.releaseDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 text-tertiary">
|
||||
<Star class="h-3.5 w-3.5 fill-current" />
|
||||
{formatRating(local.item.rating)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Progress */}
|
||||
<Show when={typeof local.progressPercent === 'number'}>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-fg">Your progress</span>
|
||||
<span class="font-semibold text-fg tabular-nums">{local.progressPercent}%</span>
|
||||
</div>
|
||||
<Progress value={local.progressPercent} variant="accent" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Genres */}
|
||||
<Show when={local.item.genres.length > 0}>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{local.item.genres.map((genre) => (
|
||||
<span class="rounded-lg bg-surface-high px-3 py-1 text-xs font-medium text-muted-fg">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Overview */}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-fg">Overview</h4>
|
||||
<p class="text-sm leading-relaxed text-muted-fg">{local.item.overview}</p>
|
||||
</div>
|
||||
|
||||
{/* Platforms (for games) */}
|
||||
<Show when={local.item.type === 'game' && local.item.platforms.length > 0}>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-fg">Platforms</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{local.item.platforms.map((platform) => (
|
||||
<span class="rounded-lg bg-secondary/10 px-3 py-1 text-xs font-medium text-secondary">
|
||||
{platform}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Meta info */}
|
||||
<div class="flex items-center gap-4 text-xs text-muted-fg">
|
||||
<span>Provider: {local.item.provider.toUpperCase()}</span>
|
||||
<span>ID: {local.item.providerId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 border-t border-outline-variant/15 px-6 py-4">
|
||||
<Show when={local.onAddToWatchlist}>
|
||||
{(onAdd) => (
|
||||
<Button variant="ghost" onClick={onAdd()}>
|
||||
<Plus class="h-4 w-4" />
|
||||
Watchlist
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={local.onMarkWatched}>
|
||||
{(onMark) => (
|
||||
<Button variant="secondary" onClick={onMark()}>
|
||||
<Eye class="h-4 w-4" />
|
||||
Mark Watched
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={local.onContinueWatching}>
|
||||
{(onContinue) => (
|
||||
<Button variant="primary" onClick={onContinue()}>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</ArkDialog.Content>
|
||||
</ArkDialog.Positioner>
|
||||
</Portal>
|
||||
</ArkDialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const MediaDetailTrigger = (props: { item: MediaItem; progressPercent?: number }) => {
|
||||
return (
|
||||
<article class="group rounded-2xl bg-surface-container overflow-hidden transition-all duration-300 hover:bg-surface-high hover:scale-[1.02] hover:shadow-ambient">
|
||||
<div class="relative h-40 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-surface-low via-surface-container to-surface-high" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<p class="font-display text-5xl font-semibold text-fg/10">
|
||||
{mediaMonogram(props.item.title)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/50 to-transparent" />
|
||||
<div class="absolute right-3 top-3 flex items-center gap-2">
|
||||
<Badge variant={mediaBadgeVariant(props.item.type)}>
|
||||
{mediaTypeLabel(props.item.type)}
|
||||
</Badge>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">
|
||||
{mediaYear(props.item.releaseDate)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={typeof props.progressPercent === 'number'}>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-surface-highest">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, Math.max(0, props.progressPercent ?? 0))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-2">
|
||||
<h4 class="line-clamp-1 text-sm font-semibold text-fg">{props.item.title}</h4>
|
||||
<p class="line-clamp-2 min-h-[2.25rem] text-xs text-muted-fg leading-relaxed">
|
||||
{props.item.overview}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-fg pt-1">
|
||||
<span>{mediaMeta(props.item) || formatRuntime(props.item.runtimeMinutes, props.item.type)}</span>
|
||||
<span class="font-semibold text-tertiary">{formatRating(props.item.rating)} ★</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const badgeVariants = cva('inline-flex items-center px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.15em] border-2', {
|
||||
variants: {
|
||||
variant: {
|
||||
neutral: 'bg-surface-high text-fg border-outline',
|
||||
accent: 'bg-primary text-on-primary border-primary',
|
||||
secondary: 'bg-fg text-surface border-fg',
|
||||
success: 'bg-success text-surface border-success',
|
||||
warning: 'bg-warning text-surface border-warning',
|
||||
danger: 'bg-danger text-on-danger border-danger',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'neutral',
|
||||
},
|
||||
})
|
||||
|
||||
interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export const Badge = (props: BadgeProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'variant'])
|
||||
return <span class={cn(badgeVariants({ variant: local.variant }), local.class)} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders default variant classes', () => {
|
||||
const { getByRole } = render(() => <Button>Run</Button>)
|
||||
const button = getByRole('button', { name: 'Run' })
|
||||
|
||||
expect(button.className).toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('supports disabled state', () => {
|
||||
const { getByRole } = render(() => (
|
||||
<Button disabled variant="secondary">
|
||||
Disabled
|
||||
</Button>
|
||||
))
|
||||
|
||||
const button = getByRole('button', { name: 'Disabled' }) as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 text-sm font-bold uppercase tracking-wider transition-all duration-100 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-40 relative overflow-hidden border-3',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
'bg-primary text-on-primary border-primary shadow-brutal hover:shadow-brutal-hover hover:translate-x-[-4px] hover:translate-y-[-4px] active:translate-x-0 active:translate-y-0 active:shadow-brutal-sm',
|
||||
secondary:
|
||||
'bg-fg text-surface border-fg shadow-brutal-outline hover:shadow-brutal-hover hover:translate-x-[-4px] hover:translate-y-[-4px] active:translate-x-0 active:translate-y-0',
|
||||
ghost: 'bg-transparent text-fg border-outline hover:bg-surface-container hover:border-fg active:bg-surface-high',
|
||||
subtle: 'bg-surface-container text-fg border-outline hover:border-fg active:bg-surface-high',
|
||||
danger: 'bg-danger text-on-danger border-danger shadow-brutal hover:shadow-brutal-hover hover:translate-x-[-4px] hover:translate-y-[-4px]',
|
||||
accent: 'bg-transparent text-primary border-primary hover:bg-primary hover:text-on-primary',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 px-4 text-xs',
|
||||
md: 'h-12 px-6 text-sm',
|
||||
lg: 'h-16 px-8 text-base',
|
||||
icon: 'h-12 w-12',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const Card = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <div class={cn('panel', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardElevated = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <div class={cn('panel-elevated', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardGlass = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <div class={cn('panel-glass', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardHeader = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <div class={cn('flex items-center justify-between gap-4 px-6 pt-6', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardTitle = (props: JSX.HTMLAttributes<HTMLHeadingElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <h3 class={cn('text-xl font-semibold text-fg tracking-tight', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardDescription = (props: JSX.HTMLAttributes<HTMLParagraphElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <p class={cn('text-sm text-muted-fg mt-1.5', local.class)} {...rest} />
|
||||
}
|
||||
|
||||
export const CardContent = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return <div class={cn('px-6 pb-6', local.class)} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Carousel as ArkCarousel } from '@ark-ui/solid/carousel'
|
||||
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-solid'
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface CarouselProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
slideCount: number
|
||||
autoplay?: boolean | { delay: number }
|
||||
loop?: boolean
|
||||
slidesPerPage?: number
|
||||
spacing?: string
|
||||
}
|
||||
|
||||
export const Carousel = ArkCarousel
|
||||
|
||||
export const CarouselRoot = (props: CarouselProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
'class',
|
||||
'slideCount',
|
||||
'autoplay',
|
||||
'loop',
|
||||
'slidesPerPage',
|
||||
'spacing',
|
||||
'children',
|
||||
])
|
||||
|
||||
return (
|
||||
<ArkCarousel.Root
|
||||
class={cn('relative w-full', local.class)}
|
||||
slideCount={local.slideCount}
|
||||
autoplay={local.autoplay}
|
||||
loop={local.loop}
|
||||
slidesPerPage={local.slidesPerPage}
|
||||
spacing={local.spacing}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkCarousel.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselItemGroup = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<ArkCarousel.ItemGroup
|
||||
class={cn(
|
||||
'flex overflow-hidden rounded-xl',
|
||||
'scrollbar-hide',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkCarousel.ItemGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CarouselItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
index: number
|
||||
}
|
||||
|
||||
export const CarouselItem = (props: CarouselItemProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'index', 'children'])
|
||||
return (
|
||||
<ArkCarousel.Item
|
||||
class={cn(
|
||||
'flex-shrink-0 flex-grow-0 basis-full',
|
||||
'focus:outline-none',
|
||||
local.class,
|
||||
)}
|
||||
index={local.index}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkCarousel.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselControl = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<ArkCarousel.Control
|
||||
class={cn('flex items-center justify-center gap-2 mt-4', local.class)}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkCarousel.Control>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselPrevTrigger = (props: JSX.HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkCarousel.PrevTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-surface-high text-muted-fg',
|
||||
'hover:bg-surface-highest hover:text-fg',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</ArkCarousel.PrevTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselNextTrigger = (props: JSX.HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkCarousel.NextTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-surface-high text-muted-fg',
|
||||
'hover:bg-surface-highest hover:text-fg',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</ArkCarousel.NextTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselAutoplayTrigger = (props: JSX.HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkCarousel.AutoplayTrigger
|
||||
class={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg',
|
||||
'bg-surface-high text-muted-fg',
|
||||
'hover:bg-surface-highest hover:text-fg',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ArkCarousel.AutoplayIndicator fallback={<Play class="h-4 w-4" />}>
|
||||
<Pause class="h-4 w-4" />
|
||||
</ArkCarousel.AutoplayIndicator>
|
||||
</ArkCarousel.AutoplayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselIndicatorGroup = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<ArkCarousel.IndicatorGroup
|
||||
class={cn('flex items-center justify-center gap-1.5', local.class)}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkCarousel.IndicatorGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CarouselIndicatorProps extends JSX.HTMLAttributes<HTMLButtonElement> {
|
||||
index: number
|
||||
}
|
||||
|
||||
export const CarouselIndicator = (props: CarouselIndicatorProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'index'])
|
||||
return (
|
||||
<ArkCarousel.Indicator
|
||||
class={cn(
|
||||
'h-1.5 w-1.5 rounded-full bg-muted-fg/40',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-muted-fg/60',
|
||||
'data-[current]:bg-primary data-[current]:w-4',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
index={local.index}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const CarouselProgressText = (props: JSX.HTMLAttributes<HTMLSpanElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkCarousel.ProgressText
|
||||
class={cn('text-xs font-medium text-muted-fg tabular-nums', local.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Show, splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type Tone = 'neutral' | 'accent' | 'secondary'
|
||||
|
||||
const toneDotClasses: Record<Tone, string> = {
|
||||
neutral: 'bg-muted-fg',
|
||||
accent: 'bg-primary',
|
||||
secondary: 'bg-secondary',
|
||||
}
|
||||
|
||||
const toneBgClasses: Record<Tone, string> = {
|
||||
neutral: 'bg-surface-container hover:bg-surface-high',
|
||||
accent: 'bg-primary/5 hover:bg-primary/10',
|
||||
secondary: 'bg-secondary/5 hover:bg-secondary/10',
|
||||
}
|
||||
|
||||
interface DataRowProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
tone?: Tone
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description?: string
|
||||
meta?: string
|
||||
badges?: JSX.Element
|
||||
trailing?: JSX.Element
|
||||
}
|
||||
|
||||
export const DataRow = (props: DataRowProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
'class',
|
||||
'tone',
|
||||
'eyebrow',
|
||||
'title',
|
||||
'description',
|
||||
'meta',
|
||||
'badges',
|
||||
'trailing',
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn('rounded-xl px-4 py-3.5 transition-all duration-200', toneBgClasses[local.tone ?? 'neutral'], local.class)}
|
||||
{...rest}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<Show when={local.eyebrow}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={cn('h-1.5 w-1.5 rounded-full', toneDotClasses[local.tone ?? 'neutral'])} />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">
|
||||
{local.eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<h3 class="text-sm font-medium text-fg">{local.title}</h3>
|
||||
<Show when={local.badges}>{local.badges}</Show>
|
||||
</div>
|
||||
|
||||
<Show when={local.description}>
|
||||
<p class="mt-1.5 text-xs text-muted-fg leading-relaxed">{local.description}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={local.meta}>
|
||||
<p class="mt-2 text-[10px] font-medium uppercase tracking-widest text-muted-fg">{local.meta}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={local.trailing}>
|
||||
<div class="shrink-0">{local.trailing}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Dialog as ArkDialog } from '@ark-ui/solid/dialog'
|
||||
import { X } from 'lucide-solid'
|
||||
import { Portal } from 'solid-js/web'
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const Dialog = ArkDialog
|
||||
|
||||
export const DialogTrigger = (props: JSX.HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<ArkDialog.Trigger class={cn(local.class)} {...rest}>
|
||||
{local.children}
|
||||
</ArkDialog.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogBackdrop = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<Portal>
|
||||
<ArkDialog.Backdrop
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogPositioner = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<Portal>
|
||||
<ArkDialog.Positioner
|
||||
class={cn('fixed inset-0 z-50 flex items-center justify-center p-4', local.class)}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkDialog.Positioner>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogContent = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'children'])
|
||||
return (
|
||||
<ArkDialog.Content
|
||||
class={cn(
|
||||
'relative w-full max-w-2xl max-h-[85vh] overflow-hidden',
|
||||
'rounded-2xl bg-surface-container shadow-panel',
|
||||
'border border-outline-variant/15',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
||||
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
||||
'focus:outline-none',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ArkDialog.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogCloseTrigger = (props: JSX.HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkDialog.CloseTrigger
|
||||
class={cn(
|
||||
'absolute right-4 top-4 z-10',
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
'bg-surface-high text-muted-fg',
|
||||
'hover:bg-surface-highest hover:text-fg',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</ArkDialog.CloseTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogTitle = (props: JSX.HTMLAttributes<HTMLHeadingElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkDialog.Title
|
||||
class={cn('text-xl font-semibold text-fg pr-8', local.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogDescription = (props: JSX.HTMLAttributes<HTMLParagraphElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<ArkDialog.Description
|
||||
class={cn('text-sm text-muted-fg mt-1', local.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogHeader = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<div class={cn('flex flex-col space-y-1.5 px-6 pt-6', local.class)} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogBody = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<div class={cn('flex-1 overflow-y-auto px-6 py-4', local.class)} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogFooter = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
return (
|
||||
<div
|
||||
class={cn('flex items-center justify-end gap-3 border-t border-outline-variant/15 px-6 py-4', local.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface EmptyStateProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const EmptyState = (props: EmptyStateProps) => {
|
||||
const [local, rest] = splitProps(props, ['class', 'title', 'description'])
|
||||
|
||||
return (
|
||||
<div class={cn('rounded-2xl bg-surface-low p-8 text-center', local.class)} {...rest}>
|
||||
<h4 class="font-display text-lg font-semibold text-fg">{local.title}</h4>
|
||||
<p class="mt-3 text-sm text-muted-fg leading-relaxed">{local.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const Input = (props: JSX.InputHTMLAttributes<HTMLInputElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
|
||||
return (
|
||||
<input
|
||||
class={cn(
|
||||
'h-11 w-full rounded-xl bg-surface-lowest px-4 text-sm text-fg outline-none transition-all duration-200 placeholder:text-muted-fg focus:bg-surface-container focus:ring-2 focus:ring-primary/30',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { LucideProps } from 'lucide-solid'
|
||||
import type { Component } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type Tone = 'neutral' | 'accent' | 'secondary'
|
||||
|
||||
const toneClasses: Record<Tone, { chip: string; line: string; shadow: string }> = {
|
||||
neutral: {
|
||||
chip: 'bg-surface-high text-fg border-3 border-outline',
|
||||
line: 'bg-outline',
|
||||
shadow: 'shadow-brutal-outline',
|
||||
},
|
||||
accent: {
|
||||
chip: 'bg-primary text-on-primary border-3 border-primary',
|
||||
line: 'bg-primary',
|
||||
shadow: 'shadow-brutal',
|
||||
},
|
||||
secondary: {
|
||||
chip: 'bg-fg text-surface border-3 border-fg',
|
||||
line: 'bg-fg',
|
||||
shadow: 'shadow-brutal-outline',
|
||||
},
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: Component<LucideProps>
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone?: Tone
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const MetricCard = (props: MetricCardProps) => {
|
||||
const Icon = props.icon
|
||||
const tone = () => toneClasses[props.tone ?? 'neutral']
|
||||
|
||||
return (
|
||||
<div class={cn(
|
||||
'group bg-surface-container p-6 transition-all duration-100 hover:translate-x-[-4px] hover:translate-y-[-4px] border-4 border-outline',
|
||||
tone().shadow,
|
||||
props.class
|
||||
)}>
|
||||
<div class={cn('h-1 w-16', tone().line)} />
|
||||
<div class="mt-6 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[9px] font-bold uppercase tracking-[0.2em] text-muted-fg font-mono">// {props.label}</p>
|
||||
<p class="mt-4 text-5xl font-bold text-fg tracking-tighter uppercase">{props.value}</p>
|
||||
<p class="mt-3 text-xs text-muted-fg uppercase tracking-wide font-mono">{props.detail}</p>
|
||||
</div>
|
||||
|
||||
<div class={cn('p-3 transition-transform duration-100 group-hover:scale-110', tone().chip)}>
|
||||
<Icon class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Progress as ArkProgress } from '@ark-ui/solid/progress'
|
||||
import { Show, splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface ProgressProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
min?: number
|
||||
label?: string
|
||||
showValue?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'default' | 'accent' | 'success' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
const progressVariants = {
|
||||
default: 'bg-primary',
|
||||
accent: 'bg-primary-container',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
danger: 'bg-danger',
|
||||
}
|
||||
|
||||
const progressSizes = {
|
||||
sm: 'h-1',
|
||||
md: 'h-1.5',
|
||||
lg: 'h-2',
|
||||
}
|
||||
|
||||
export const Progress = (props: ProgressProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
'class',
|
||||
'value',
|
||||
'max',
|
||||
'min',
|
||||
'label',
|
||||
'showValue',
|
||||
'size',
|
||||
'variant',
|
||||
])
|
||||
|
||||
const size = () => local.size ?? 'md'
|
||||
const variant = () => local.variant ?? 'default'
|
||||
const maxValue = () => local.max ?? 100
|
||||
const minValue = () => local.min ?? 0
|
||||
const currentValue = () => local.value ?? 0
|
||||
|
||||
return (
|
||||
<ArkProgress.Root
|
||||
class={cn('relative', local.class)}
|
||||
value={currentValue()}
|
||||
max={maxValue()}
|
||||
min={minValue()}
|
||||
{...rest}
|
||||
>
|
||||
<Show when={local.label || local.showValue}>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<Show when={local.label}>
|
||||
<ArkProgress.Label class="text-xs font-medium text-muted-fg">
|
||||
{local.label}
|
||||
</ArkProgress.Label>
|
||||
</Show>
|
||||
<Show when={local.showValue}>
|
||||
<ArkProgress.ValueText class="text-xs font-semibold text-fg tabular-nums">
|
||||
{Math.round(currentValue())}%
|
||||
</ArkProgress.ValueText>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<ArkProgress.Track
|
||||
class={cn(
|
||||
'relative w-full overflow-hidden rounded-full bg-surface-high',
|
||||
progressSizes[size()],
|
||||
)}
|
||||
>
|
||||
<ArkProgress.Range
|
||||
class={cn(
|
||||
'h-full rounded-full transition-all duration-300 ease-out',
|
||||
progressVariants[variant()],
|
||||
)}
|
||||
/>
|
||||
</ArkProgress.Track>
|
||||
</ArkProgress.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CircularProgressProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
variant?: 'default' | 'accent' | 'success' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
const circleVariants = {
|
||||
default: 'stroke-primary',
|
||||
accent: 'stroke-primary-container',
|
||||
success: 'stroke-success',
|
||||
warning: 'stroke-warning',
|
||||
danger: 'stroke-danger',
|
||||
}
|
||||
|
||||
export const CircularProgress = (props: CircularProgressProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
'class',
|
||||
'value',
|
||||
'max',
|
||||
'size',
|
||||
'strokeWidth',
|
||||
'variant',
|
||||
])
|
||||
|
||||
const size = () => local.size ?? 48
|
||||
const strokeWidth = () => local.strokeWidth ?? 4
|
||||
const variant = () => local.variant ?? 'default'
|
||||
const maxValue = () => local.max ?? 100
|
||||
const currentValue = () => local.value ?? 0
|
||||
|
||||
const radius = () => (size() - strokeWidth()) / 2
|
||||
const circumference = () => radius() * 2 * Math.PI
|
||||
const offset = () => circumference() - (currentValue() / maxValue()) * circumference()
|
||||
|
||||
return (
|
||||
<ArkProgress.Root
|
||||
class={cn('inline-flex', local.class)}
|
||||
value={currentValue()}
|
||||
max={maxValue()}
|
||||
{...rest}
|
||||
>
|
||||
<ArkProgress.Circle
|
||||
class="relative"
|
||||
style={{ width: `${size()}px`, height: `${size()}px` }}
|
||||
>
|
||||
<ArkProgress.CircleTrack
|
||||
class="stroke-surface-high"
|
||||
style={{ "stroke-width": `${strokeWidth()}px` }}
|
||||
/>
|
||||
<ArkProgress.CircleRange
|
||||
class={cn(
|
||||
'transition-all duration-300 ease-out',
|
||||
circleVariants[variant()],
|
||||
)}
|
||||
style={{
|
||||
"stroke-width": `${strokeWidth()}px`,
|
||||
"stroke-dasharray": `${circumference()}px`,
|
||||
"stroke-dashoffset": `${offset()}px`,
|
||||
transform: 'rotate(-90deg)',
|
||||
"transform-origin": '50% 50%',
|
||||
}}
|
||||
/>
|
||||
<ArkProgress.ValueText class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-fg tabular-nums">
|
||||
{Math.round(currentValue())}%
|
||||
</ArkProgress.ValueText>
|
||||
</ArkProgress.Circle>
|
||||
</ArkProgress.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Show, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface SectionHeadingProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description?: string
|
||||
badge?: JSX.Element
|
||||
action?: JSX.Element
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const SectionHeading = (props: SectionHeadingProps) => (
|
||||
<div class={cn('flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', props.class)}>
|
||||
<div class="min-w-0">
|
||||
<Show when={props.eyebrow}>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">{props.eyebrow}</p>
|
||||
</Show>
|
||||
<h2 class="mt-3 text-2xl font-semibold text-fg leading-tight">{props.title}</h2>
|
||||
<Show when={props.description}>
|
||||
<p class="mt-3 max-w-2xl text-sm text-muted-fg leading-relaxed">{props.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-3">
|
||||
<Show when={props.badge}>{props.badge}</Show>
|
||||
<Show when={props.action}>{props.action}</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { splitProps, type JSX } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const Skeleton = (props: JSX.HTMLAttributes<HTMLDivElement>) => {
|
||||
const [local, rest] = splitProps(props, ['class'])
|
||||
|
||||
return <div class={cn('shimmer rounded-2xl', local.class)} data-testid="skeleton" {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createSignal } from 'solid-js'
|
||||
import { render } from '@solidjs/testing-library'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('changes selection when option is clicked', () => {
|
||||
const Harness = () => {
|
||||
const [value, setValue] = createSignal('all')
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
label="Media"
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'movie', label: 'Movies' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { getByRole } = render(() => <Harness />)
|
||||
const movies = getByRole('tab', { name: 'Movies' })
|
||||
|
||||
movies.click()
|
||||
|
||||
expect(movies.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { For } from 'solid-js'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface TabOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
label: string
|
||||
options: TabOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
class?: string
|
||||
}
|
||||
|
||||
export const Tabs = (props: TabsProps) => (
|
||||
<div class={cn('inline-flex rounded-xl bg-surface-low p-1', props.class)} role="tablist">
|
||||
<For each={props.options}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={props.value === option.value}
|
||||
class={cn(
|
||||
'rounded-lg px-4 py-2 text-xs font-semibold transition-all duration-200',
|
||||
props.value === option.value
|
||||
? 'bg-surface-container text-fg'
|
||||
: 'text-muted-fg hover:text-fg',
|
||||
)}
|
||||
onClick={() => props.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,521 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* BRUTALIST CINEMA - /seen/
|
||||
Aggressive. Unapologetic. Unforgettable.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* BRUTALIST PALETTE - Pure, Aggressive, Uncompromising */
|
||||
--color-surface-dim: 0 0 0;
|
||||
--color-surface: 0 0 0;
|
||||
--color-surface-bright: 18 18 18;
|
||||
--color-surface-container-lowest: 0 0 0;
|
||||
--color-surface-container-low: 10 10 10;
|
||||
--color-surface-container: 15 15 15;
|
||||
--color-surface-container-high: 25 25 25;
|
||||
--color-surface-container-highest: 35 35 35;
|
||||
|
||||
/* Legacy mappings */
|
||||
--color-bg: var(--color-surface);
|
||||
--color-fg: 255 255 255;
|
||||
--color-muted: var(--color-surface-container-low);
|
||||
--color-muted-fg: 140 140 140;
|
||||
--color-card: var(--color-surface-container);
|
||||
--color-border: 40 40 40;
|
||||
|
||||
/* Primary - HOT PINK (The Unforgettable Accent) */
|
||||
--color-primary: 255 0 110;
|
||||
--color-primary-container: 200 0 85;
|
||||
--color-primary-fixed: 255 102 170;
|
||||
--color-primary-fixed-dim: 255 51 140;
|
||||
--color-on-primary: 0 0 0;
|
||||
--color-on-primary-container: 255 255 255;
|
||||
|
||||
/* Accent alias */
|
||||
--color-accent: var(--color-primary);
|
||||
|
||||
/* Secondary - Pure White (High Contrast) */
|
||||
--color-secondary: 255 255 255;
|
||||
--color-secondary-container: 200 200 200;
|
||||
--color-secondary-fixed: 255 255 255;
|
||||
--color-secondary-fixed-dim: 230 230 230;
|
||||
--color-on-secondary: 0 0 0;
|
||||
--color-on-secondary-container: 0 0 0;
|
||||
|
||||
/* Tertiary - Electric Yellow */
|
||||
--color-tertiary: 255 255 0;
|
||||
--color-tertiary-container: 200 200 0;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: 0 255 0;
|
||||
--color-warning: 255 255 0;
|
||||
--color-danger: 255 0 0;
|
||||
--color-on-danger: 0 0 0;
|
||||
|
||||
/* Outlines & Variants */
|
||||
--color-outline: 80 80 80;
|
||||
--color-outline-variant: 40 40 40;
|
||||
--color-on-surface-variant: 180 180 180;
|
||||
|
||||
/* Inverse */
|
||||
--color-inverse-surface: 255 255 255;
|
||||
--color-inverse-on-surface: 0 0 0;
|
||||
--color-inverse-primary: 200 0 85;
|
||||
|
||||
/* Surface Tint */
|
||||
--color-surface-tint: 255 0 110;
|
||||
|
||||
/* Brutalist Effects */
|
||||
--noise-opacity: 0.08;
|
||||
--impact-scale: 1.0;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surface Architecture */
|
||||
--color-surface-dim: 219 216 215;
|
||||
--color-surface: 246 246 244;
|
||||
--color-surface-bright: 246 245 244;
|
||||
--color-surface-container-lowest: 255 255 255;
|
||||
--color-surface-container-low: 240 239 238;
|
||||
--color-surface-container: 234 233 232;
|
||||
--color-surface-container-high: 228 227 226;
|
||||
--color-surface-container-highest: 221 220 219;
|
||||
|
||||
/* Legacy mappings */
|
||||
--color-bg: var(--color-surface);
|
||||
--color-fg: 28 28 28;
|
||||
--color-muted: var(--color-surface-container-low);
|
||||
--color-muted-fg: 100 100 100;
|
||||
--color-card: var(--color-surface-container);
|
||||
--color-border: 200 199 198;
|
||||
|
||||
/* Primary */
|
||||
--color-primary: 0 110 255;
|
||||
--color-primary-container: 0 110 255;
|
||||
--color-primary-fixed: 217 226 255;
|
||||
--color-primary-fixed-dim: 177 197 255;
|
||||
--color-on-primary: 255 255 255;
|
||||
--color-on-primary-container: 0 4 22;
|
||||
|
||||
--color-accent: var(--color-primary-container);
|
||||
|
||||
/* Secondary */
|
||||
--color-secondary: 99 26 179;
|
||||
--color-secondary-container: 233 220 255;
|
||||
--color-secondary-fixed: 233 220 255;
|
||||
--color-secondary-fixed-dim: 208 188 255;
|
||||
--color-on-secondary: 255 255 255;
|
||||
--color-on-secondary-container: 35 0 92;
|
||||
|
||||
/* Tertiary */
|
||||
--color-tertiary: 0 136 93;
|
||||
--color-tertiary-container: 111 255 190;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: 0 136 93;
|
||||
--color-warning: 167 119 0;
|
||||
--color-danger: 186 26 26;
|
||||
--color-on-danger: 255 218 214;
|
||||
|
||||
/* Outlines */
|
||||
--color-outline: 113 117 128;
|
||||
--color-outline-variant: 196 199 208;
|
||||
--color-on-surface-variant: 113 117 128;
|
||||
|
||||
/* Inverse */
|
||||
--color-inverse-surface: 49 48 48;
|
||||
--color-inverse-on-surface: 241 239 238;
|
||||
--color-inverse-primary: 177 197 255;
|
||||
|
||||
--color-surface-tint: 177 197 255;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface text-fg font-sans;
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #000000;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* BRUTAL NOISE TEXTURE */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='3.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
opacity: var(--noise-opacity);
|
||||
z-index: 9999;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* AGGRESSIVE TYPOGRAPHY */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
@apply font-display;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* HOT PINK SELECTION */
|
||||
::selection {
|
||||
background-color: rgb(var(--color-primary));
|
||||
color: rgb(0 0 0);
|
||||
}
|
||||
|
||||
/* HARD EDGES EVERYWHERE */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* BRUTALIST PANELS - Hard Edges, High Contrast */
|
||||
.panel {
|
||||
@apply bg-surface-container;
|
||||
border: 3px solid rgb(var(--color-outline));
|
||||
box-shadow: 8px 8px 0 rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.panel-elevated {
|
||||
@apply bg-surface-high;
|
||||
border: 4px solid rgb(var(--color-primary));
|
||||
box-shadow: 12px 12px 0 rgb(var(--color-outline));
|
||||
}
|
||||
|
||||
.panel-glass {
|
||||
@apply bg-surface-container;
|
||||
border: 2px solid rgb(var(--color-outline-variant));
|
||||
}
|
||||
|
||||
/* AGGRESSIVE ACCENTS */
|
||||
.gradient-primary {
|
||||
background: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.gradient-secondary {
|
||||
background: rgb(var(--color-secondary));
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgb(var(--color-primary) / 0.03) 10px,
|
||||
rgb(var(--color-primary) / 0.03) 20px
|
||||
);
|
||||
}
|
||||
|
||||
/* BRUTAL BORDERS */
|
||||
.brutal-border {
|
||||
border: 3px solid rgb(var(--color-fg));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brutal-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border: 3px solid rgb(var(--color-primary));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* IMPACT SHADOWS */
|
||||
.shadow-brutal {
|
||||
box-shadow: 8px 8px 0 rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.shadow-brutal-lg {
|
||||
box-shadow: 12px 12px 0 rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.shadow-brutal-hover {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.shadow-brutal-hover:hover {
|
||||
transform: translate(-4px, -4px);
|
||||
box-shadow: 12px 12px 0 rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
/* INSTANT ANIMATIONS - No Easing */
|
||||
.animate-stagger {
|
||||
animation: brutal-appear 0.1s steps(1) both;
|
||||
}
|
||||
|
||||
.animate-scale {
|
||||
animation: brutal-scale 0.1s steps(2) both;
|
||||
}
|
||||
|
||||
/* GLITCH EFFECT */
|
||||
.glitch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glitch::before,
|
||||
.glitch::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.glitch::before {
|
||||
left: 2px;
|
||||
text-shadow: -2px 0 rgb(var(--color-primary));
|
||||
clip: rect(24px, 550px, 90px, 0);
|
||||
animation: glitch-anim 3s infinite linear alternate-reverse;
|
||||
}
|
||||
|
||||
.glitch::after {
|
||||
left: -2px;
|
||||
text-shadow: -2px 0 rgb(var(--color-tertiary));
|
||||
clip: rect(85px, 550px, 140px, 0);
|
||||
animation: glitch-anim 2s infinite linear alternate-reverse;
|
||||
}
|
||||
|
||||
/* SCANLINES */
|
||||
.scanlines {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0px,
|
||||
transparent 2px,
|
||||
rgb(var(--color-fg) / 0.03) 2px,
|
||||
rgb(var(--color-fg) / 0.03) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* SCROLLBAR - BRUTAL STYLE */
|
||||
.scrollbar-brutal {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--color-primary)) rgb(var(--color-surface-container));
|
||||
}
|
||||
|
||||
.scrollbar-brutal::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.scrollbar-brutal::-webkit-scrollbar-track {
|
||||
background: rgb(var(--color-surface-container));
|
||||
border: 2px solid rgb(var(--color-outline));
|
||||
}
|
||||
|
||||
.scrollbar-brutal::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--color-primary));
|
||||
border: 2px solid rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.scrollbar-brutal::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--color-primary-fixed));
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Stagger Animation Delays */
|
||||
.stagger-1 { animation-delay: 50ms; }
|
||||
.stagger-2 { animation-delay: 100ms; }
|
||||
.stagger-3 { animation-delay: 150ms; }
|
||||
.stagger-4 { animation-delay: 200ms; }
|
||||
.stagger-5 { animation-delay: 250ms; }
|
||||
.stagger-6 { animation-delay: 300ms; }
|
||||
}
|
||||
|
||||
/* BRUTAL ANIMATIONS - Hard Cuts, No Easing */
|
||||
@keyframes brutal-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brutal-scale {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-anim {
|
||||
0% {
|
||||
clip: rect(42px, 9999px, 44px, 0);
|
||||
}
|
||||
5% {
|
||||
clip: rect(12px, 9999px, 59px, 0);
|
||||
}
|
||||
10% {
|
||||
clip: rect(48px, 9999px, 29px, 0);
|
||||
}
|
||||
15% {
|
||||
clip: rect(42px, 9999px, 73px, 0);
|
||||
}
|
||||
20% {
|
||||
clip: rect(63px, 9999px, 27px, 0);
|
||||
}
|
||||
25% {
|
||||
clip: rect(34px, 9999px, 55px, 0);
|
||||
}
|
||||
30% {
|
||||
clip: rect(86px, 9999px, 73px, 0);
|
||||
}
|
||||
35% {
|
||||
clip: rect(20px, 9999px, 20px, 0);
|
||||
}
|
||||
40% {
|
||||
clip: rect(26px, 9999px, 60px, 0);
|
||||
}
|
||||
45% {
|
||||
clip: rect(25px, 9999px, 66px, 0);
|
||||
}
|
||||
50% {
|
||||
clip: rect(57px, 9999px, 98px, 0);
|
||||
}
|
||||
55% {
|
||||
clip: rect(5px, 9999px, 46px, 0);
|
||||
}
|
||||
60% {
|
||||
clip: rect(82px, 9999px, 31px, 0);
|
||||
}
|
||||
65% {
|
||||
clip: rect(54px, 9999px, 27px, 0);
|
||||
}
|
||||
70% {
|
||||
clip: rect(28px, 9999px, 99px, 0);
|
||||
}
|
||||
75% {
|
||||
clip: rect(45px, 9999px, 69px, 0);
|
||||
}
|
||||
80% {
|
||||
clip: rect(23px, 9999px, 85px, 0);
|
||||
}
|
||||
85% {
|
||||
clip: rect(54px, 9999px, 84px, 0);
|
||||
}
|
||||
90% {
|
||||
clip: rect(45px, 9999px, 47px, 0);
|
||||
}
|
||||
95% {
|
||||
clip: rect(37px, 9999px, 20px, 0);
|
||||
}
|
||||
100% {
|
||||
clip: rect(4px, 9999px, 91px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
41.99% {
|
||||
opacity: 1;
|
||||
}
|
||||
42% {
|
||||
opacity: 0;
|
||||
}
|
||||
43% {
|
||||
opacity: 0;
|
||||
}
|
||||
43.01% {
|
||||
opacity: 1;
|
||||
}
|
||||
47.99% {
|
||||
opacity: 1;
|
||||
}
|
||||
48% {
|
||||
opacity: 0;
|
||||
}
|
||||
49% {
|
||||
opacity: 0;
|
||||
}
|
||||
49.01% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-brutal {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 1ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* @refresh reload */
|
||||
import '@fontsource/plus-jakarta-sans/400.css'
|
||||
import '@fontsource/plus-jakarta-sans/500.css'
|
||||
import '@fontsource/plus-jakarta-sans/600.css'
|
||||
import '@fontsource/plus-jakarta-sans/700.css'
|
||||
import '@fontsource/outfit/400.css'
|
||||
import '@fontsource/outfit/600.css'
|
||||
import '@fontsource/outfit/700.css'
|
||||
import { render } from 'solid-js/web'
|
||||
import { App } from '@/app/App'
|
||||
import './index.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element #root was not found')
|
||||
}
|
||||
|
||||
render(
|
||||
() => <App />,
|
||||
root,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Activity, Database, HardDrive, Shield, Wrench } from 'lucide-solid'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
|
||||
export const AdminPage = () => (
|
||||
<section class="space-y-6" data-testid="admin-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-8 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Admin"
|
||||
title="Operational controls and system diagnostics."
|
||||
description="A compact control plane for service health, storage, workers, and background operations."
|
||||
badge={<Badge variant="secondary">Admin access</Badge>}
|
||||
action={
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="subtle" size="sm">Run health check</Button>
|
||||
<Button variant="subtle" size="sm">Sync metadata</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-surface-low p-5 transition-all duration-200 hover:bg-surface-container">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">API</p>
|
||||
<p class="mt-3 text-2xl font-semibold text-fg">Healthy</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-surface-low p-5 transition-all duration-200 hover:bg-surface-container">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Workers</p>
|
||||
<p class="mt-3 text-2xl font-semibold text-fg">2 active</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-surface-low p-5 transition-all duration-200 hover:bg-surface-container">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Storage</p>
|
||||
<p class="mt-3 text-2xl font-semibold text-fg">68%</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-surface-low p-5 transition-all duration-200 hover:bg-surface-container">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Queue</p>
|
||||
<p class="mt-3 text-2xl font-semibold text-fg">Stable</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
title="Service overview"
|
||||
description="Snapshot of core runtime surfaces."
|
||||
badge={<Badge variant="neutral">Runtime telemetry</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Backend API"
|
||||
title="Request handling healthy"
|
||||
description="Health and readiness endpoints are responding."
|
||||
trailing={<Activity class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Database"
|
||||
title="PostgreSQL connection stable"
|
||||
description="Primary read/write pipeline is online."
|
||||
trailing={<Database class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Filesystem"
|
||||
title="Mounted media volume available"
|
||||
description="Library and downloader paths are writable."
|
||||
trailing={<HardDrive class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
title="Guardrails"
|
||||
description="Operational safety and policy checks."
|
||||
badge={<Badge variant="secondary">Security posture</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Auth"
|
||||
title="Role-based access enabled"
|
||||
description="Admin routes are isolated from user routes."
|
||||
trailing={<Shield class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Maintenance"
|
||||
title="Task runners available"
|
||||
description="Background jobs can be restarted without downtime."
|
||||
trailing={<Wrench class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
import { CalendarClock, Clapperboard, Filter, Gamepad2, Tv } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dashboardService } from '@/services/dashboard-service'
|
||||
import type { MediaItem, MediaType } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const monthLabel = (date: string): string =>
|
||||
new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(date))
|
||||
|
||||
const typeIcon = (type: MediaType) => {
|
||||
if (type === 'movie') return Clapperboard
|
||||
if (type === 'show') return Tv
|
||||
return Gamepad2
|
||||
}
|
||||
|
||||
export const CalendarPage = () => {
|
||||
const [activeType, setActiveType] = createSignal<MediaType | 'all'>('all')
|
||||
const [dashboardData, { refetch }] = createResource(() => dashboardService.getDashboard())
|
||||
const upcoming = createMemo(() => dashboardData()?.upcoming ?? [])
|
||||
|
||||
const monthGroups = createMemo(() => {
|
||||
const filtered = upcoming().filter((item) => (activeType() === 'all' ? true : item.type === activeType()))
|
||||
|
||||
const grouped = new Map<string, MediaItem[]>()
|
||||
for (const item of filtered) {
|
||||
const key = monthLabel(item.releaseDate)
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), item])
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([label, items]) => ({
|
||||
label,
|
||||
items: items.sort((a, b) => +new Date(a.releaseDate) - +new Date(b.releaseDate)),
|
||||
}))
|
||||
})
|
||||
|
||||
const totals = createMemo(() => ({
|
||||
all: upcoming().length,
|
||||
movie: upcoming().filter((item) => item.type === 'movie').length,
|
||||
show: upcoming().filter((item) => item.type === 'show').length,
|
||||
game: upcoming().filter((item) => item.type === 'game').length,
|
||||
}))
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="calendar-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Release Calendar"
|
||||
title="Track upcoming movies, episodes, and game launches in one timeline."
|
||||
description="A calm scheduling surface tuned for planning your next week of watch and play sessions."
|
||||
badge={<Badge variant="neutral">{totals().all} upcoming releases</Badge>}
|
||||
action={
|
||||
<Button variant="subtle" size="sm" onClick={() => refetch()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Movies</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{totals().movie}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Shows</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{totals().show}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Games</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{totals().game}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Months</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{monthGroups().length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="neutral">
|
||||
<Filter class="mr-1 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Badge>
|
||||
<Button size="sm" variant={activeType() === 'all' ? 'primary' : 'ghost'} onClick={() => setActiveType('all')}>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeType() === 'movie' ? 'primary' : 'ghost'}
|
||||
onClick={() => setActiveType('movie')}
|
||||
>
|
||||
Movies
|
||||
</Button>
|
||||
<Button size="sm" variant={activeType() === 'show' ? 'primary' : 'ghost'} onClick={() => setActiveType('show')}>
|
||||
Shows
|
||||
</Button>
|
||||
<Button size="sm" variant={activeType() === 'game' ? 'primary' : 'ghost'} onClick={() => setActiveType('game')}>
|
||||
Games
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={dashboardData.loading && upcoming().length === 0}>
|
||||
<div class="space-y-4">
|
||||
<For each={Array.from({ length: 3 }, (_, index) => index)}>{() => <Skeleton class="h-36" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={monthGroups().length > 0}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent class="pt-6">
|
||||
<EmptyState
|
||||
title="No upcoming releases"
|
||||
description="The release calendar will populate as upcoming content is synced."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<For each={monthGroups()}>
|
||||
{(group) => (
|
||||
<Card>
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-fg">{group.label}</h3>
|
||||
<p class="mt-1 text-sm text-muted-fg">{group.items.length} scheduled items</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<CalendarClock class="mr-1 h-3.5 w-3.5" />
|
||||
Timeline
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const Icon = typeIcon(item.type)
|
||||
return (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(item.type)}
|
||||
eyebrow={mediaTypeLabel(item.type)}
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
badges={<Badge variant={mediaBadgeVariant(item.type)}>{mediaTypeLabel(item.type)}</Badge>}
|
||||
trailing={
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-border bg-muted text-fg">
|
||||
<Icon class="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={dashboardData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{dashboardData.error instanceof Error ? dashboardData.error.message : 'Failed to load calendar'}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { FolderHeart, LayoutGrid, Plus, Sparkles } from 'lucide-solid'
|
||||
import { For } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { dashboardPayload } from '@/services/mock-data'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const collectionSeeds = [
|
||||
{
|
||||
id: 'col-1',
|
||||
name: 'Weekend Adrenaline',
|
||||
description: 'Fast-paced action films and high-momentum games.',
|
||||
items: dashboardPayload.watchLater.slice(0, 3),
|
||||
updatedAt: 'Updated 2h ago',
|
||||
},
|
||||
{
|
||||
id: 'col-2',
|
||||
name: 'Slow-Burn Sci‑Fi',
|
||||
description: 'Long-form worlds and cerebral pacing.',
|
||||
items: dashboardPayload.trending.filter((item) => item.genres.includes('Sci-Fi')).slice(0, 3),
|
||||
updatedAt: 'Updated yesterday',
|
||||
},
|
||||
{
|
||||
id: 'col-3',
|
||||
name: 'Launch Radar',
|
||||
description: 'Upcoming titles likely to move into queue quickly.',
|
||||
items: dashboardPayload.upcoming.slice(0, 3),
|
||||
updatedAt: 'Updated today',
|
||||
},
|
||||
]
|
||||
|
||||
export const CollectionsPage = () => (
|
||||
<section class="space-y-6" data-testid="collections-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Collections"
|
||||
title="Curate custom shelves across movies, shows, and games."
|
||||
description="Keep personal themed sets lightweight and easy to maintain."
|
||||
badge={<Badge variant="neutral">{collectionSeeds.length} collections</Badge>}
|
||||
action={
|
||||
<Button variant="subtle" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
New collection
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Collections</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{collectionSeeds.length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Total items</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">
|
||||
{collectionSeeds.reduce((acc, collection) => acc + collection.items.length, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Multi-type</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">
|
||||
{
|
||||
collectionSeeds.filter((collection) => new Set(collection.items.map((item) => item.type)).size > 1)
|
||||
.length
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Featured</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">Launch Radar</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_360px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Collection grid"
|
||||
description="Your current shelves with compact previews."
|
||||
badge={<Badge variant="accent">{collectionSeeds.length} active</Badge>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={collectionSeeds}>
|
||||
{(collection) => (
|
||||
<Card class="border-border/80">
|
||||
<CardContent class="space-y-4 pt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-fg">{collection.name}</h3>
|
||||
<p class="mt-1 text-xs text-muted-fg">{collection.description}</p>
|
||||
</div>
|
||||
<Badge variant="neutral">{collection.items.length} items</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={collection.items}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(item.type)}
|
||||
eyebrow={mediaTypeLabel(item.type)}
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
badges={<Badge variant={mediaBadgeVariant(item.type)}>{mediaTypeLabel(item.type)}</Badge>}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-fg">{collection.updatedAt}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Collection patterns"
|
||||
description="Signals that help keep shelves useful."
|
||||
badge={<Badge variant="secondary">Guidance</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Balance"
|
||||
title="Mix one discovery shelf with one comfort shelf"
|
||||
description="Keeps queue freshness high while preserving familiar picks."
|
||||
trailing={<Sparkles class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Scale"
|
||||
title="Aim for 6–12 items per collection"
|
||||
description="Large shelves become hard to navigate and lose intent."
|
||||
trailing={<LayoutGrid class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Ownership"
|
||||
title="Promote completed items out of active shelves"
|
||||
description="Helps collections remain action-oriented."
|
||||
trailing={<FolderHeart class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="pt-6">
|
||||
<EmptyState
|
||||
title="Collection editing API pending"
|
||||
description="Create/update/delete persistence hooks can be connected once collection endpoints are live."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Film, Gamepad2, Play, Sparkles } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
||||
import { HeroCarousel } from '@/components/media/hero-carousel'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dashboardService } from '@/services/dashboard-service'
|
||||
import { progressService } from '@/services/progress-service'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import type { ContinueWatchingItem, RecommendationItem } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const cardSkeletons = (count: number) => Array.from({ length: count }, (_, index) => index)
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const auth = useAuth()
|
||||
const [reloadToken, setReloadToken] = createSignal(0)
|
||||
const [progressBusyKey, setProgressBusyKey] = createSignal<string | null>(null)
|
||||
const [progressActionError, setProgressActionError] = createSignal<string | null>(null)
|
||||
|
||||
const [dashboardData, { refetch: refetchDashboard }] = createResource(reloadToken, () =>
|
||||
dashboardService.getDashboard(),
|
||||
)
|
||||
|
||||
const [continueWatchingData, { refetch: refetchContinue }] = createResource(reloadToken, async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return []
|
||||
}
|
||||
|
||||
return progressService.getContinueWatching(accessToken)
|
||||
})
|
||||
|
||||
const dashboard = createMemo(() => dashboardData())
|
||||
const continueEntries = createMemo(() => continueWatchingData() ?? [])
|
||||
const recommendations = createMemo(() => dashboard()?.recommendations ?? [])
|
||||
const trending = createMemo(() => dashboard()?.trending ?? [])
|
||||
const upcoming = createMemo(() => dashboard()?.upcoming ?? [])
|
||||
const watchLater = createMemo(() => dashboard()?.watchLater ?? [])
|
||||
const gameBacklog = createMemo(() => dashboard()?.gameBacklog ?? [])
|
||||
|
||||
const averageRecommendationScore = createMemo(() => {
|
||||
const items = recommendations()
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.round(items.reduce((total, entry) => total + entry.score, 0) / items.length)
|
||||
})
|
||||
|
||||
const refresh = (): void => {
|
||||
setReloadToken((value) => value + 1)
|
||||
}
|
||||
|
||||
const retryAll = (): void => {
|
||||
refetchDashboard()
|
||||
refetchContinue()
|
||||
}
|
||||
|
||||
const updateEntryProgress = async (entry: ContinueWatchingItem, nextPercent: number): Promise<void> => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
setProgressActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
const busyKey = `${entry.item.id}:${entry.progress.episodeNumber}:${nextPercent}`
|
||||
setProgressBusyKey(busyKey)
|
||||
setProgressActionError(null)
|
||||
|
||||
try {
|
||||
await progressService.updateProgress(accessToken, {
|
||||
mediaId: entry.item.id,
|
||||
seasonNumber: entry.progress.seasonNumber,
|
||||
episodeNumber: entry.progress.episodeNumber,
|
||||
progressPercent: nextPercent,
|
||||
})
|
||||
refresh()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to update playback progress'
|
||||
setProgressActionError(message)
|
||||
} finally {
|
||||
setProgressBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const recommendationMeta = (entry: RecommendationItem): string =>
|
||||
`${mediaTypeLabel(entry.media.type)} · ${entry.media.genres.slice(0, 2).join(' • ')}`
|
||||
|
||||
return (
|
||||
<section class="space-y-12" data-testid="dashboard-page">
|
||||
{/* Hero Carousel */}
|
||||
<Show when={trending().length > 0}>
|
||||
<div class="animate-brutal-appear border-4 border-primary shadow-brutal-lg">
|
||||
<HeroCarousel items={trending().slice(0, 5)} autoplay loop />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-10">
|
||||
{/* MASSIVE PAGE HEADER */}
|
||||
<div class="flex items-end justify-between animate-brutal-appear border-b-4 border-primary pb-6">
|
||||
<div>
|
||||
<h1 class="text-6xl sm:text-8xl font-bold text-fg tracking-tighter uppercase leading-none">
|
||||
WELCOME<br/>BACK
|
||||
</h1>
|
||||
<p class="text-sm text-primary mt-4 uppercase tracking-[0.2em] font-mono font-bold">// YOUR MEDIA UNIVERSE</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="md" onClick={refresh} class="hidden sm:flex">
|
||||
REFRESH
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* BRUTAL STATS GRID */}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Play}
|
||||
label="Continue Watching"
|
||||
value={String(continueEntries().length)}
|
||||
detail={continueEntries().length === 1 ? 'title' : 'titles'}
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Sparkles}
|
||||
label="Recommendations"
|
||||
value={averageRecommendationScore() ? `${averageRecommendationScore()}%` : '—'}
|
||||
detail={recommendations().length > 0 ? `${recommendations().length} picks` : 'analyzing'}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Film}
|
||||
label="Watch Later"
|
||||
value={String(watchLater().length)}
|
||||
detail="queued titles"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Gamepad2}
|
||||
label="Game Backlog"
|
||||
value={String(gameBacklog().length)}
|
||||
detail="to play"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={progressActionError()}>
|
||||
<div class="bg-danger border-4 border-danger px-6 py-4 text-sm text-on-danger font-bold uppercase tracking-wide shadow-brutal">
|
||||
{progressActionError()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* CONTINUE WATCHING SECTION */}
|
||||
<Card class="animate-brutal-appear">
|
||||
<CardContent class="space-y-8 pt-8">
|
||||
<div class="flex items-center justify-between border-b-4 border-outline pb-4">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold text-fg tracking-tighter uppercase">CONTINUE</h2>
|
||||
<p class="text-xs text-muted-fg mt-2 uppercase tracking-wider font-mono">// PICK UP WHERE YOU LEFT OFF</p>
|
||||
</div>
|
||||
<Show when={continueEntries().length > 0}>
|
||||
<Badge variant="accent" class="text-xs px-4 py-2">{continueEntries().length} ACTIVE</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={continueWatchingData.loading && continueEntries().length === 0}>
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<For each={cardSkeletons(3)}>{() => <Skeleton class="h-80 border-4 border-outline" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={continueEntries().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="NOTHING IN PROGRESS"
|
||||
description="Start watching something and it'll appear here."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<For each={continueEntries().slice(0, 6)}>
|
||||
{(entry) => {
|
||||
const resumeKey = () => `${entry.item.id}:${entry.progress.episodeNumber}:${Math.min(99, entry.progress.progressPercent + 15)}`
|
||||
|
||||
return (
|
||||
<div class="space-y-4 group">
|
||||
<MediaCard
|
||||
item={entry.item}
|
||||
progressPercent={entry.progress.progressPercent}
|
||||
subtitle={`S${entry.progress.seasonNumber} E${entry.progress.episodeNumber} · ${formatDate(entry.progress.lastWatchedAt)}`}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
disabled={progressBusyKey() === resumeKey()}
|
||||
onClick={() =>
|
||||
void updateEntryProgress(
|
||||
entry,
|
||||
Math.min(99, entry.progress.progressPercent + 15),
|
||||
)
|
||||
}
|
||||
>
|
||||
{progressBusyKey() === resumeKey() ? 'UPDATING…' : 'RESUME'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RECOMMENDATIONS SECTION */}
|
||||
<Card class="animate-brutal-appear">
|
||||
<CardContent class="space-y-8 pt-8">
|
||||
<div class="flex items-center justify-between border-b-4 border-outline pb-4">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold text-fg tracking-tighter uppercase">RECOMMENDED</h2>
|
||||
<p class="text-xs text-muted-fg mt-2 uppercase tracking-wider font-mono">// CURATED FOR YOUR TASTE</p>
|
||||
</div>
|
||||
<Show when={recommendations().length > 0}>
|
||||
<Badge variant="neutral" class="text-xs px-4 py-2">{recommendations().length} PICKS</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={dashboardData.loading && recommendations().length === 0}>
|
||||
<div class="space-y-4">
|
||||
<For each={cardSkeletons(3)}>{() => <Skeleton class="h-28 border-4 border-outline" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={recommendations().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="NO RECOMMENDATIONS YET"
|
||||
description="Watch more content to get personalized suggestions."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<For each={recommendations().slice(0, 4)}>
|
||||
{(entry) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(entry.media.type)}
|
||||
eyebrow={`MATCH ${entry.score}%`}
|
||||
title={entry.media.title}
|
||||
description={entry.reason}
|
||||
meta={recommendationMeta(entry)}
|
||||
badges={
|
||||
<Badge variant={mediaBadgeVariant(entry.media.type)}>
|
||||
{mediaTypeLabel(entry.media.type)}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* UPCOMING RELEASES */}
|
||||
<Show when={upcoming().length > 0}>
|
||||
<Card class="animate-brutal-appear">
|
||||
<CardContent class="space-y-8 pt-8">
|
||||
<div class="flex items-center justify-between border-b-4 border-outline pb-4">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold text-fg tracking-tighter uppercase">UPCOMING</h2>
|
||||
<p class="text-xs text-muted-fg mt-2 uppercase tracking-wider font-mono">// ON YOUR RADAR</p>
|
||||
</div>
|
||||
<Badge variant="secondary" class="text-xs px-4 py-2">{upcoming().length} TITLES</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<For each={upcoming().slice(0, 4)}>
|
||||
{(item) => (
|
||||
<MediaCard
|
||||
item={item}
|
||||
subtitle={`${mediaTypeLabel(item.type)} · ${item.genres.slice(0, 2).join(' • ')}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* ERROR STATE */}
|
||||
<Show when={dashboardData.error || continueWatchingData.error}>
|
||||
<div class="flex items-center justify-between bg-danger border-4 border-danger px-6 py-4 text-sm text-on-danger font-bold uppercase tracking-wide shadow-brutal">
|
||||
<span>
|
||||
{dashboardData.error?.message ?? continueWatchingData.error?.message ?? 'UNABLE TO LOAD DASHBOARD'}
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" onClick={retryAll}>
|
||||
RETRY
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Search } from 'lucide-solid'
|
||||
import { For, Show, createEffect, createMemo, createResource, createSignal, on } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
import { discoverService } from '@/services/discover-service'
|
||||
import { genres } from '@/services/mock-data'
|
||||
import { searchService } from '@/services/search-service'
|
||||
import type { DiscoverSection } from '@/types/domain'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const PAGE_SIZE = 4
|
||||
const DEFAULT_VISIBLE_GENRES = 6
|
||||
|
||||
const mediaTypeOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'movie', label: 'Movies' },
|
||||
{ value: 'show', label: 'Shows' },
|
||||
{ value: 'game', label: 'Games' },
|
||||
] as const
|
||||
|
||||
const mergeSections = (current: DiscoverSection[], incoming: DiscoverSection[]): DiscoverSection[] => {
|
||||
const incomingByKind = new Map(incoming.map((section) => [section.kind, section]))
|
||||
|
||||
const mergedCurrent = current.map((section) => {
|
||||
const nextSection = incomingByKind.get(section.kind)
|
||||
|
||||
if (!nextSection) {
|
||||
return section
|
||||
}
|
||||
|
||||
const knownIds = new Set(section.items.map((item) => item.id))
|
||||
const additionalItems = nextSection.items.filter((item) => !knownIds.has(item.id))
|
||||
|
||||
return {
|
||||
...nextSection,
|
||||
items: [...section.items, ...additionalItems],
|
||||
}
|
||||
})
|
||||
|
||||
const appendedSections = incoming.filter(
|
||||
(section) => !current.some((currentSection) => currentSection.kind === section.kind),
|
||||
)
|
||||
|
||||
return [...mergedCurrent, ...appendedSections]
|
||||
}
|
||||
|
||||
const FilterChip = (props: { active: boolean; onClick: () => void; children: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-200',
|
||||
props.active
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-surface-low text-muted-fg hover:text-fg hover:bg-surface-container',
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
|
||||
const discoverScopeLabel = (value: 'all' | 'movie' | 'show' | 'game'): string =>
|
||||
value === 'all' ? 'All media' : mediaTypeLabel(value)
|
||||
|
||||
export const DiscoverPage = () => {
|
||||
const [query, setQuery] = createSignal('')
|
||||
const [genre, setGenre] = createSignal<string>('all')
|
||||
const [mediaType, setMediaType] = createSignal<'all' | 'movie' | 'show' | 'game'>('all')
|
||||
const [page, setPage] = createSignal(1)
|
||||
const [sections, setSections] = createSignal<DiscoverSection[]>([])
|
||||
const [showAllGenres, setShowAllGenres] = createSignal(false)
|
||||
|
||||
const params = createMemo(() => ({
|
||||
page: page(),
|
||||
pageSize: PAGE_SIZE,
|
||||
query: query().trim(),
|
||||
genre: genre() === 'all' ? undefined : genre(),
|
||||
mediaType: mediaType(),
|
||||
}))
|
||||
|
||||
const [fetchedSections, { refetch: refetchSections }] = createResource(params, (next) =>
|
||||
discoverService.getSections(next),
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(
|
||||
() => query().trim(),
|
||||
async (term) => {
|
||||
if (term.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
return searchService.search(term, {
|
||||
genre: genre() === 'all' ? undefined : genre(),
|
||||
mediaType: mediaType(),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [query().trim(), genre(), mediaType()],
|
||||
() => {
|
||||
setPage(1)
|
||||
setSections([])
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const incoming = fetchedSections()
|
||||
|
||||
if (!incoming) {
|
||||
return
|
||||
}
|
||||
|
||||
if (page() === 1) {
|
||||
setSections(incoming)
|
||||
return
|
||||
}
|
||||
|
||||
setSections((current) => mergeSections(current, incoming))
|
||||
})
|
||||
|
||||
const visibleGenres = createMemo(() =>
|
||||
showAllGenres() ? genres : genres.slice(0, DEFAULT_VISIBLE_GENRES),
|
||||
)
|
||||
const queryActive = createMemo(() => query().trim().length >= 2)
|
||||
const searchLead = createMemo(() => searchResults()?.[0] ?? null)
|
||||
const searchRest = createMemo(() => (searchResults() ?? []).slice(1, 4))
|
||||
const canLoadMore = createMemo(() =>
|
||||
(sections().length > 0 || fetchedSections.loading) && !queryActive(),
|
||||
)
|
||||
|
||||
return (
|
||||
<section class="space-y-8" data-testid="discover-page">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-fg">Discover</h1>
|
||||
<p class="text-sm text-muted-fg mt-1">Find your next watch or play</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:w-80">
|
||||
<Search class="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-fg" />
|
||||
<Input
|
||||
aria-label="Discover search"
|
||||
placeholder="Search titles..."
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
class="h-10 pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Tabs
|
||||
label="Media type"
|
||||
value={mediaType()}
|
||||
onChange={(value) => setMediaType(value as typeof mediaTypeOptions[number]['value'])}
|
||||
options={mediaTypeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
/>
|
||||
<Show when={queryActive()}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setQuery('')}>
|
||||
Clear
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<FilterChip active={genre() === 'all'} onClick={() => setGenre('all')}>
|
||||
All
|
||||
</FilterChip>
|
||||
<For each={visibleGenres()}>
|
||||
{(genreName) => (
|
||||
<FilterChip active={genre() === genreName} onClick={() => setGenre(genreName)}>
|
||||
{genreName}
|
||||
</FilterChip>
|
||||
)}
|
||||
</For>
|
||||
<Show when={genres.length > DEFAULT_VISIBLE_GENRES && !showAllGenres()}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAllGenres(true)}>
|
||||
More
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={queryActive()}>
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6" data-testid="discover-search-results">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-fg">Search Results</h2>
|
||||
<p class="text-sm text-muted-fg mt-1">Found {(searchResults() ?? []).length} matches</p>
|
||||
</div>
|
||||
<Badge variant="accent">{discoverScopeLabel(mediaType())}</Badge>
|
||||
</div>
|
||||
|
||||
<Show when={searchResults.loading}>
|
||||
<p class="text-sm text-muted-fg">Searching…</p>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={searchLead()}
|
||||
fallback={
|
||||
<Show when={!searchResults.loading}>
|
||||
<EmptyState
|
||||
title="Nothing matched"
|
||||
description="Try a different search term."
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(lead) => (
|
||||
<div class="grid gap-5 lg:grid-cols-[1fr_280px]">
|
||||
<MediaCard
|
||||
item={{
|
||||
id: lead().id,
|
||||
artworkKey: lead().title,
|
||||
genres: lead().genres,
|
||||
overview: lead().subtitle,
|
||||
platforms: [],
|
||||
provider: 'tmdb',
|
||||
providerId: lead().id,
|
||||
rating: Math.max(7, Math.min(9.5, lead().score / 10)),
|
||||
releaseDate: '2026-03-12',
|
||||
runtimeMinutes: 120,
|
||||
title: lead().title,
|
||||
type: lead().mediaType,
|
||||
}}
|
||||
subtitle={lead().subtitle}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={searchRest()}>
|
||||
{(result) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(result.mediaType)}
|
||||
eyebrow={`Score ${result.score}`}
|
||||
title={result.title}
|
||||
description={result.subtitle}
|
||||
badges={
|
||||
<Badge variant={mediaBadgeVariant(result.mediaType)}>
|
||||
{mediaTypeLabel(result.mediaType)}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={fetchedSections.error}>
|
||||
<div class="flex items-center justify-between rounded-xl bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<span>{fetchedSections.error.message}</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchSections()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!queryActive()}>
|
||||
<For each={sections().slice(0, 3)}>
|
||||
{(section) => (
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-fg">{section.title}</h2>
|
||||
<p class="text-sm text-muted-fg mt-1">{section.subtitle}</p>
|
||||
</div>
|
||||
<Badge variant="neutral">{section.items.length}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<For each={section.items}>
|
||||
{(item) => <MediaCard item={item} subtitle={`${mediaTypeLabel(item.type)} · ${item.genres.slice(0, 2).join(' • ')}`} />}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={fetchedSections.loading && sections().length === 0}>
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<For each={Array.from({ length: 4 }, (_, index) => index)}>{() => <Skeleton class="h-64" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={sections().length === 0 && !fetchedSections.loading && !fetchedSections.error && !queryActive()}>
|
||||
<EmptyState
|
||||
title="Nothing to show"
|
||||
description="Try a different filter."
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={canLoadMore()}>
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
disabled={fetchedSections.loading}
|
||||
>
|
||||
{fetchedSections.loading ? 'Loading…' : 'Load More'}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Activity, Download, Plus, TimerReset, Zap } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
||||
import { DownloadCard } from '@/components/media/download-card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { downloadService, type DownloadCreateInput } from '@/services/download-service'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
|
||||
export const DownloadsPage = () => {
|
||||
const auth = useAuth()
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
const [activeDownloadsData, { refetch }] = createResource(async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return []
|
||||
}
|
||||
|
||||
return downloadService.list(accessToken, { limit: 30 })
|
||||
})
|
||||
const activeDownloads = createMemo(() =>
|
||||
(activeDownloadsData() ?? []).filter((job) =>
|
||||
['queued', 'downloading', 'stalled'].includes(job.status),
|
||||
),
|
||||
)
|
||||
const totalThroughput = createMemo(() =>
|
||||
activeDownloads().reduce((total, job) => total + job.downloadSpeedMbps, 0),
|
||||
)
|
||||
const stalledCount = createMemo(() => activeDownloads().filter((job) => job.status === 'stalled').length)
|
||||
const queuedCount = createMemo(() => activeDownloads().filter((job) => job.status === 'queued').length)
|
||||
const downloadingCount = createMemo(() => activeDownloads().filter((job) => job.status === 'downloading').length)
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
const accessToken = auth.accessToken()
|
||||
if (!accessToken) {
|
||||
setActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setActionError(null)
|
||||
await downloadService.cancel(accessToken, id)
|
||||
await refetch()
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : 'Failed to cancel download')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSample = async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
if (!accessToken) {
|
||||
setActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
const sampleInput: DownloadCreateInput = {
|
||||
sourceType: 'http',
|
||||
source: 'https://example.com/sample-media.mkv',
|
||||
title: 'Sample download',
|
||||
}
|
||||
try {
|
||||
setActionError(null)
|
||||
await downloadService.create(accessToken, sampleInput)
|
||||
await refetch()
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : 'Failed to create download')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="space-y-8" data-testid="downloads-page">
|
||||
{/* Header */}
|
||||
<div class="animate-stagger space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-fg">Downloads</h1>
|
||||
<p class="text-sm text-muted-fg mt-1">Manage your active transfers and queue</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleAddSample}>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add Download
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={() => refetch()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Download}
|
||||
label="Active"
|
||||
value={String(downloadingCount())}
|
||||
detail="downloading now"
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Zap}
|
||||
label="Throughput"
|
||||
value={activeDownloads().length > 0 ? `${totalThroughput().toFixed(1)} MB/s` : '0 MB/s'}
|
||||
detail="combined speed"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Activity}
|
||||
label="Stalled"
|
||||
value={String(stalledCount())}
|
||||
detail="need attention"
|
||||
tone="secondary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={TimerReset}
|
||||
label="Queued"
|
||||
value={String(queuedCount())}
|
||||
detail="waiting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Downloads Grid */}
|
||||
<Show when={activeDownloadsData.loading && activeDownloads().length === 0}>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<For each={Array.from({ length: 6 }, (_, i) => i)}>{() => <Skeleton class="h-40" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={activeDownloads().length > 0}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<EmptyState
|
||||
title="No active downloads"
|
||||
description="Add a magnet link, torrent, or direct download to get started."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<For each={activeDownloads()}>
|
||||
{(job) => (
|
||||
<DownloadCard
|
||||
job={job}
|
||||
onCancel={() => handleCancel(job.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error State */}
|
||||
<Show when={actionError() || activeDownloadsData.error}>
|
||||
<div class="flex items-center justify-between rounded-xl bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<span>
|
||||
{actionError() ??
|
||||
(activeDownloadsData.error instanceof Error
|
||||
? activeDownloadsData.error.message
|
||||
: 'Failed to load downloads')}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Clock3, Gamepad2, Radar, RefreshCw, Search, Sparkles } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { gamesService } from '@/services/games-service'
|
||||
import { searchService } from '@/services/search-service'
|
||||
import { watchLaterService } from '@/services/watch-later-service'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import type { DiscoverSection, MediaItem, SearchResult } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const sectionByKind = (sections: DiscoverSection[], kind: DiscoverSection['kind']) =>
|
||||
sections.find((section) => section.kind === kind)
|
||||
|
||||
const SearchResultRow = (props: {
|
||||
result: SearchResult
|
||||
queued: boolean
|
||||
busy: boolean
|
||||
onToggle: () => void
|
||||
}) => (
|
||||
<DataRow
|
||||
tone={props.queued ? 'secondary' : 'accent'}
|
||||
eyebrow={`Score ${props.result.score}`}
|
||||
title={props.result.title}
|
||||
description={props.result.subtitle}
|
||||
badges={<Badge variant="neutral">Game</Badge>}
|
||||
trailing={
|
||||
<Button size="sm" variant={props.queued ? 'secondary' : 'primary'} disabled={props.busy} onClick={props.onToggle}>
|
||||
{props.busy ? 'Saving…' : props.queued ? 'Queued' : 'Add'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
export const GamesPage = () => {
|
||||
const auth = useAuth()
|
||||
const [reloadToken, setReloadToken] = createSignal(0)
|
||||
const [query, setQuery] = createSignal('')
|
||||
const [busyMediaId, setBusyMediaId] = createSignal<number | null>(null)
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
|
||||
const [gameSections, { refetch: refetchSections }] = createResource(reloadToken, () =>
|
||||
gamesService.getSections({ page: 1, pageSize: 4 }),
|
||||
)
|
||||
|
||||
const [queueData, { refetch: refetchQueue }] = createResource(reloadToken, async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = await watchLaterService.getWatchLater(accessToken)
|
||||
return items.filter((item) => item.type === 'game')
|
||||
})
|
||||
|
||||
const [searchResults] = createResource(
|
||||
() => query().trim(),
|
||||
async (term) => {
|
||||
if (term.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
return searchService.search(term, { mediaType: 'game' })
|
||||
},
|
||||
)
|
||||
|
||||
const queueItems = createMemo(() => queueData() ?? [])
|
||||
const queueIds = createMemo(() => new Set(queueItems().map((item) => item.id)))
|
||||
const anticipatedSection = createMemo(() => sectionByKind(gameSections() ?? [], 'most-anticipated-games'))
|
||||
const releasedSection = createMemo(() => sectionByKind(gameSections() ?? [], 'recently-released-games'))
|
||||
const indieSection = createMemo(() => sectionByKind(gameSections() ?? [], 'indie-highlights'))
|
||||
const trendingSection = createMemo(() => sectionByKind(gameSections() ?? [], 'trending'))
|
||||
const queueHours = createMemo(() =>
|
||||
Math.round(queueItems().reduce((total, item) => total + item.runtimeMinutes, 0) / 60),
|
||||
)
|
||||
const queryActive = createMemo(() => query().trim().length >= 2)
|
||||
|
||||
const refresh = (): void => {
|
||||
setReloadToken((value) => value + 1)
|
||||
}
|
||||
|
||||
const retryAll = (): void => {
|
||||
refetchSections()
|
||||
refetchQueue()
|
||||
}
|
||||
|
||||
const toggleQueue = async (mediaId: number): Promise<void> => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
setActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
setActionError(null)
|
||||
setBusyMediaId(mediaId)
|
||||
|
||||
try {
|
||||
if (queueIds().has(mediaId)) {
|
||||
await watchLaterService.removeWatchLater(accessToken, mediaId)
|
||||
} else {
|
||||
await watchLaterService.addWatchLater(accessToken, mediaId)
|
||||
}
|
||||
|
||||
refresh()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to update the backlog'
|
||||
setActionError(message)
|
||||
} finally {
|
||||
setBusyMediaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const shelfRows = (items: MediaItem[] | undefined) => items?.slice(0, 4) ?? []
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="games-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Games"
|
||||
title="Keep the backlog lean and the launch radar readable."
|
||||
description="Search titles, save the good ones, and keep upcoming releases visible without letting the page sprawl."
|
||||
badge={<Badge variant="neutral">{queueItems().length} queued</Badge>}
|
||||
action={
|
||||
<Button variant="subtle" size="sm" onClick={retryAll}>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-fg" />
|
||||
<Input
|
||||
aria-label="Games search"
|
||||
placeholder="Search game titles, genres, and platforms"
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
class="h-12 pl-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[1.5rem] border border-border bg-muted/45 px-4 py-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-muted-fg">Control state</p>
|
||||
<p class="mt-2 text-sm text-fg">
|
||||
{queryActive()
|
||||
? `Searching for “${query().trim()}” while keeping queue actions one click away.`
|
||||
: queueItems().length > 0
|
||||
? `${queueItems().length} queued games spanning about ${Math.max(1, queueHours())}h.`
|
||||
: 'No backlog yet. Start with search or the launch radar below.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Gamepad2}
|
||||
label="Backlog"
|
||||
value={`${queueItems().length}`}
|
||||
detail={queueItems().length === 1 ? 'queued title' : 'queued titles'}
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Clock3}
|
||||
label="Playtime"
|
||||
value={queueItems().length > 0 ? `${Math.max(1, queueHours())}h` : '0h'}
|
||||
detail="estimated queue depth"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Radar}
|
||||
label="On radar"
|
||||
value={`${anticipatedSection()?.items.length ?? 0}`}
|
||||
detail="tracked launches"
|
||||
tone="secondary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Sparkles}
|
||||
label="Indie picks"
|
||||
value={`${indieSection()?.items.length ?? 0}`}
|
||||
detail="smaller releases worth a look"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={actionError()}>
|
||||
<div class="rounded-[1.4rem] border border-secondary/24 bg-secondary/10 px-4 py-3 text-sm text-fg">
|
||||
{actionError()}
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.02fr)_380px]">
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Queued Games"
|
||||
description="Titles already promoted into your backlog."
|
||||
badge={<Badge variant="accent">{queueItems().length} saved</Badge>}
|
||||
/>
|
||||
|
||||
<Show when={queueData.loading && queueItems().length === 0}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={Array.from({ length: 4 }, (_, index) => index)}>{() => <Skeleton class="h-[280px]" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={queueItems().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No backlog yet"
|
||||
description="Save a few games from search or the launch radar to build your queue."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={queueItems().slice(0, 4)}>
|
||||
{(item) => (
|
||||
<div class="space-y-3">
|
||||
<MediaCard item={item} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busyMediaId() === item.id}
|
||||
onClick={() => void toggleQueue(item.id)}
|
||||
>
|
||||
{busyMediaId() === item.id ? 'Saving…' : 'Remove from queue'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={queryActive()}>
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Search Results"
|
||||
description="Add titles directly from search without leaving the page."
|
||||
badge={<Badge variant="neutral">{(searchResults() ?? []).length} matches</Badge>}
|
||||
/>
|
||||
|
||||
<Show when={searchResults.loading}>
|
||||
<p class="text-sm text-muted-fg">Searching…</p>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={(searchResults() ?? []).length > 0}
|
||||
fallback={
|
||||
<Show when={!searchResults.loading}>
|
||||
<EmptyState
|
||||
title="Nothing matched"
|
||||
description="Try a broader title or clear the search to return to the radar view."
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={searchResults()}>
|
||||
{(result) => (
|
||||
<SearchResultRow
|
||||
result={result}
|
||||
queued={queueIds().has(result.id)}
|
||||
busy={busyMediaId() === result.id}
|
||||
onToggle={() => void toggleQueue(result.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Indie Highlights"
|
||||
description="Smaller games with sharper ideas."
|
||||
badge={<Badge variant="neutral">{indieSection()?.items.length ?? 0} loaded</Badge>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={indieSection()?.items.slice(0, 4) ?? []}>
|
||||
{(item) => <MediaCard item={item} subtitle={item.platforms.slice(0, 2).join(' • ')} />}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Most Anticipated"
|
||||
description="Upcoming launches currently worth tracking."
|
||||
badge={<Badge variant="secondary">{anticipatedSection()?.items.length ?? 0} tracked</Badge>}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={shelfRows(anticipatedSection()?.items)}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Launch radar"
|
||||
title={item.title}
|
||||
description={item.platforms.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
badges={<Badge variant={mediaBadgeVariant(item.type)}>{mediaTypeLabel(item.type)}</Badge>}
|
||||
trailing={
|
||||
<Button
|
||||
size="sm"
|
||||
variant={queueIds().has(item.id) ? 'secondary' : 'primary'}
|
||||
disabled={busyMediaId() === item.id}
|
||||
onClick={() => void toggleQueue(item.id)}
|
||||
>
|
||||
{busyMediaId() === item.id ? 'Saving…' : queueIds().has(item.id) ? 'Queued' : 'Add'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Recently Released"
|
||||
description="Fresh drops already live in the catalog."
|
||||
badge={<Badge variant="neutral">{releasedSection()?.items.length ?? 0} recent</Badge>}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={shelfRows(releasedSection()?.items)}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Recent release"
|
||||
title={item.title}
|
||||
description={item.platforms.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Trending"
|
||||
description="Games spilling into the wider discover surface."
|
||||
badge={<Badge variant="neutral">{trendingSection()?.items.length ?? 0} active</Badge>}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={shelfRows(trendingSection()?.items)}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Trending"
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={gameSections.error || queueData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{gameSections.error?.message ?? queueData.error?.message ?? 'Unable to load the games surface.'}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { CheckCircle2, Database, Film, FolderSearch, Gamepad2, Link2, ShieldAlert, Tv } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createSignal } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { mediaCatalog } from '@/services/mock-data'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
type MatchState = 'matched' | 'unmatched' | 'review'
|
||||
type LibraryTab = 'all' | 'movies' | 'shows' | 'games'
|
||||
|
||||
interface LibraryEntry {
|
||||
id: string
|
||||
fileName: string
|
||||
absolutePath: string
|
||||
discoveredAt: string
|
||||
matchState: MatchState
|
||||
confidence: number
|
||||
media?: MediaItem
|
||||
}
|
||||
|
||||
const entries: LibraryEntry[] = [
|
||||
{
|
||||
id: 'lib-1',
|
||||
fileName: 'Zero.Meridian.2026.2160p.HDR.mkv',
|
||||
absolutePath: '/mnt/library/movies/Zero Meridian (2026)/Zero.Meridian.2026.2160p.HDR.mkv',
|
||||
discoveredAt: '2026-03-10T10:18:00Z',
|
||||
matchState: 'matched',
|
||||
confidence: 0.96,
|
||||
media: mediaCatalog.find((item) => item.id === 9),
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
fileName: 'Abyss.Echo.S02E02.1080p.mkv',
|
||||
absolutePath: '/mnt/library/shows/Abyss Echo/Season 02/Abyss.Echo.S02E02.1080p.mkv',
|
||||
discoveredAt: '2026-03-10T10:22:00Z',
|
||||
matchState: 'matched',
|
||||
confidence: 0.93,
|
||||
media: mediaCatalog.find((item) => item.id === 17),
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
fileName: 'Star-Circuit-Zero-v1.0.iso',
|
||||
absolutePath: '/mnt/library/games/Star Circuit Zero/Star-Circuit-Zero-v1.0.iso',
|
||||
discoveredAt: '2026-03-10T10:31:00Z',
|
||||
matchState: 'review',
|
||||
confidence: 0.67,
|
||||
media: mediaCatalog.find((item) => item.id === 25),
|
||||
},
|
||||
{
|
||||
id: 'lib-4',
|
||||
fileName: 'Unknown.Release.CAM.avi',
|
||||
absolutePath: '/mnt/library/inbox/Unknown.Release.CAM.avi',
|
||||
discoveredAt: '2026-03-10T10:47:00Z',
|
||||
matchState: 'unmatched',
|
||||
confidence: 0.19,
|
||||
},
|
||||
{
|
||||
id: 'lib-5',
|
||||
fileName: 'Neon.Divide.2025.4K.REMUX.mkv',
|
||||
absolutePath: '/mnt/library/movies/Neon Divide (2025)/Neon.Divide.2025.4K.REMUX.mkv',
|
||||
discoveredAt: '2026-03-11T14:22:00Z',
|
||||
matchState: 'matched',
|
||||
confidence: 0.98,
|
||||
media: mediaCatalog.find((item) => item.id === 12),
|
||||
},
|
||||
{
|
||||
id: 'lib-6',
|
||||
fileName: 'Last.Light.Harbor.S01E07.1080p.mkv',
|
||||
absolutePath: '/mnt/library/shows/Last Light Harbor/Season 01/Last.Light.Harbor.S01E07.1080p.mkv',
|
||||
discoveredAt: '2026-03-11T15:05:00Z',
|
||||
matchState: 'matched',
|
||||
confidence: 0.91,
|
||||
media: mediaCatalog.find((item) => item.id === 14),
|
||||
},
|
||||
{
|
||||
id: 'lib-7',
|
||||
fileName: 'Ghostline.Kyoto-PS5.pkg',
|
||||
absolutePath: '/mnt/library/games/Ghostline Kyoto/Ghostline.Kyoto-PS5.pkg',
|
||||
discoveredAt: '2026-03-12T09:15:00Z',
|
||||
matchState: 'matched',
|
||||
confidence: 0.89,
|
||||
media: mediaCatalog.find((item) => item.id === 26),
|
||||
},
|
||||
].filter((item): item is LibraryEntry => Boolean(item))
|
||||
|
||||
const stateTone = (state: MatchState): 'accent' | 'neutral' | 'secondary' => {
|
||||
if (state === 'matched') return 'accent'
|
||||
if (state === 'review') return 'neutral'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const stateLabel = (state: MatchState): string => {
|
||||
if (state === 'matched') return 'Matched'
|
||||
if (state === 'review') return 'Needs review'
|
||||
return 'Unmatched'
|
||||
}
|
||||
|
||||
export const LibraryPage = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<LibraryTab>('all')
|
||||
|
||||
const matched = createMemo(() => entries.filter((item) => item.matchState === 'matched'))
|
||||
const review = createMemo(() => entries.filter((item) => item.matchState === 'review'))
|
||||
const unmatched = createMemo(() => entries.filter((item) => item.matchState === 'unmatched'))
|
||||
const avgConfidence = createMemo(() =>
|
||||
Math.round((entries.reduce((acc, item) => acc + item.confidence, 0) / entries.length) * 100),
|
||||
)
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const tab = activeTab()
|
||||
if (tab === 'all') return matched()
|
||||
return matched().filter((entry) => entry.media?.type === (tab === 'movies' ? 'movie' : tab === 'shows' ? 'show' : 'game'))
|
||||
})
|
||||
|
||||
const tabs: { id: LibraryTab; label: string; icon: typeof Film; count: number }[] = [
|
||||
{ id: 'all', label: 'All', icon: Database, count: matched().length },
|
||||
{ id: 'movies', label: 'Movies', icon: Film, count: matched().filter((e) => e.media?.type === 'movie').length },
|
||||
{ id: 'shows', label: 'Shows', icon: Tv, count: matched().filter((e) => e.media?.type === 'show').length },
|
||||
{ id: 'games', label: 'Games', icon: Gamepad2, count: matched().filter((e) => e.media?.type === 'game').length },
|
||||
]
|
||||
|
||||
return (
|
||||
<section class="space-y-8" data-testid="library-page">
|
||||
{/* Header */}
|
||||
<div class="animate-stagger space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-fg">Library</h1>
|
||||
<p class="text-sm text-muted-fg mt-1">Your indexed media collection</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="subtle" size="sm">Start Scan</Button>
|
||||
<Button variant="primary" size="sm">Review Unmatched</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={CheckCircle2}
|
||||
label="Matched"
|
||||
value={String(matched().length)}
|
||||
detail="linked files"
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ShieldAlert}
|
||||
label="Needs Review"
|
||||
value={String(review().length)}
|
||||
detail="low confidence"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={FolderSearch}
|
||||
label="Unmatched"
|
||||
value={String(unmatched().length)}
|
||||
detail="no metadata"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Link2}
|
||||
label="Avg Confidence"
|
||||
value={`${avgConfidence()}%`}
|
||||
detail="match quality"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="flex items-center gap-1 rounded-xl bg-surface-container p-1">
|
||||
<For each={tabs}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
class={cn(
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
activeTab() === tab.id
|
||||
? 'bg-surface-high text-fg shadow-sm'
|
||||
: 'text-muted-fg hover:text-fg hover:bg-surface-high/50',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
{tab.label}
|
||||
<span class={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
activeTab() === tab.id ? 'bg-primary/20 text-primary' : 'bg-surface-container-high text-muted-fg',
|
||||
)}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Matched Grid */}
|
||||
<Show
|
||||
when={filteredEntries().length > 0}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<EmptyState
|
||||
title="No matched files"
|
||||
description="Run a scan to start indexing your media library."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<For each={filteredEntries()}>
|
||||
{(entry) => (
|
||||
<Show when={entry.media}>
|
||||
{(media) => (
|
||||
<div class="space-y-2">
|
||||
<MediaCard
|
||||
item={media()}
|
||||
subtitle={`${Math.round(entry.confidence * 100)}% confidence`}
|
||||
/>
|
||||
<p class="line-clamp-1 text-xs text-muted-fg px-1">{entry.fileName}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Review Queue */}
|
||||
<Show when={review().length + unmatched().length > 0}>
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-fg">Review Queue</h2>
|
||||
<p class="text-sm text-muted-fg mt-1">Items needing manual attention</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{review().length + unmatched().length} pending</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={[...review(), ...unmatched()]}>
|
||||
{(entry) => (
|
||||
<DataRow
|
||||
tone={stateTone(entry.matchState)}
|
||||
eyebrow={stateLabel(entry.matchState)}
|
||||
title={entry.fileName}
|
||||
description={entry.media ? `${entry.media.title} candidate` : 'No candidate match found'}
|
||||
meta={`${Math.round(entry.confidence * 100)}% confidence · ${formatDate(entry.discoveredAt)}`}
|
||||
badges={
|
||||
entry.media ? (
|
||||
<Badge variant={mediaBadgeVariant(entry.media.type)}>{mediaTypeLabel(entry.media.type)}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Unresolved</Badge>
|
||||
)
|
||||
}
|
||||
trailing={
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-border bg-muted text-fg">
|
||||
{entry.matchState === 'unmatched' ? (
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
) : (
|
||||
<Database class="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function cn(...classes: (string | boolean | undefined)[]) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { ArrowRight, Film, Gamepad2, Shield, Tv } from 'lucide-solid'
|
||||
import { For, Show, createSignal, type Component } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { isMockApiEnabled } from '@/services/api-client'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import { useTheme } from '@/stores/theme-store'
|
||||
import type { ThemeMode } from '@/types/domain'
|
||||
import { mediaMonogram } from '@/utils/media'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type IconComponent = Component<{ class?: string; size?: number | string }>
|
||||
|
||||
const formModes = [
|
||||
{ value: 'login', label: 'Sign In' },
|
||||
{ value: 'register', label: 'Register' },
|
||||
] as const
|
||||
|
||||
const themeModes: { value: ThemeMode; label: string }[] = [
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'system', label: 'System' },
|
||||
]
|
||||
|
||||
const featureHighlights: { title: string; description: string; icon: IconComponent; tone: 'accent' | 'secondary' | 'neutral' }[] = [
|
||||
{
|
||||
title: 'Unified queue',
|
||||
description: 'Movies, shows, and games stay on one quieter surface instead of across several dashboards.',
|
||||
icon: Film,
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
title: 'Playback memory',
|
||||
description: 'Resume points, backlog context, and queue state stay synced to your local session.',
|
||||
icon: Tv,
|
||||
tone: 'secondary',
|
||||
},
|
||||
{
|
||||
title: 'Private by default',
|
||||
description: 'Seen is built around a self-hosted setup, not a hosted SaaS posture.',
|
||||
icon: Shield,
|
||||
tone: 'neutral',
|
||||
},
|
||||
]
|
||||
|
||||
const previewRows = [
|
||||
{
|
||||
label: 'Continue watching',
|
||||
title: 'The Bear',
|
||||
detail: 'Episode 4 at 61%',
|
||||
tone: 'accent' as const,
|
||||
},
|
||||
{
|
||||
label: 'Queue',
|
||||
title: 'Blade Runner 2049',
|
||||
detail: 'Saved for tonight',
|
||||
tone: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
label: 'Backlog',
|
||||
title: 'Hades II',
|
||||
detail: 'Tracked for the next long session',
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
]
|
||||
|
||||
export const LoginPage = () => {
|
||||
const auth = useAuth()
|
||||
const theme = useTheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [mode, setMode] = createSignal<'login' | 'register'>('login')
|
||||
const [email, setEmail] = createSignal('demo@seen.local')
|
||||
const [password, setPassword] = createSignal('password123')
|
||||
const [displayName, setDisplayName] = createSignal('Demo User')
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const sessionLabel = (): string => (isMockApiEnabled() ? 'Demo-ready auth' : 'Live auth connected')
|
||||
const sessionSummary = (): string =>
|
||||
isMockApiEnabled()
|
||||
? 'Demo credentials are prefilled so the frontend can be validated immediately.'
|
||||
: 'Authentication is using the configured backend session for this instance.'
|
||||
const sessionDetails = (): string =>
|
||||
isMockApiEnabled()
|
||||
? 'Use demo@seen.local with password123, or register a fresh local profile.'
|
||||
: 'Sign in with an existing account, or create a new local session.'
|
||||
|
||||
const redirectPath = (): string => {
|
||||
const search = typeof window === 'undefined' ? '' : window.location.search
|
||||
const redirect = new URLSearchParams(search).get('redirect')
|
||||
|
||||
if (!redirect || !redirect.startsWith('/app/')) {
|
||||
return '/app/dashboard'
|
||||
}
|
||||
|
||||
return redirect
|
||||
}
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (auth.isBusy()) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (mode() === 'register') {
|
||||
await auth.register({
|
||||
email: email().trim(),
|
||||
password: password(),
|
||||
displayName: displayName().trim(),
|
||||
})
|
||||
} else {
|
||||
await auth.login({
|
||||
email: email().trim(),
|
||||
password: password(),
|
||||
})
|
||||
}
|
||||
|
||||
navigate(redirectPath(), { replace: true })
|
||||
} catch (submissionError) {
|
||||
const message =
|
||||
submissionError instanceof Error ? submissionError.message : 'Authentication request failed'
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main class="min-h-screen bg-surface-dim px-6 py-6 sm:px-8 lg:px-10">
|
||||
<div class="mx-auto grid min-h-[calc(100vh-3rem)] max-w-6xl gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section class="panel flex flex-col justify-between p-8 sm:p-10">
|
||||
<div class="space-y-10">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="grid h-12 w-12 place-items-center rounded-xl bg-primary text-on-primary shadow-glow">
|
||||
<span class="font-display text-xl font-semibold">S</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-muted-fg">Seen</p>
|
||||
<p class="mt-1 text-sm text-muted-fg">Personal streaming control center</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant="neutral">Self-hosted</Badge>
|
||||
</div>
|
||||
|
||||
<SectionHeading
|
||||
eyebrow="Private control"
|
||||
title="Keep your media stack on one cleaner surface."
|
||||
description="Seen brings your next watch, resume history, downloads, and backlog into a single calm interface with no dashboard clutter."
|
||||
/>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<For each={featureHighlights}>
|
||||
{(highlight) => {
|
||||
const Icon = highlight.icon
|
||||
|
||||
return (
|
||||
<DataRow
|
||||
tone={highlight.tone}
|
||||
title={highlight.title}
|
||||
description={highlight.description}
|
||||
trailing={
|
||||
<div
|
||||
class={cn(
|
||||
'flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200',
|
||||
highlight.tone === 'accent'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: highlight.tone === 'secondary'
|
||||
? 'bg-secondary/15 text-secondary'
|
||||
: 'bg-surface-high text-muted-fg',
|
||||
)}
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-surface-low p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Surface preview</p>
|
||||
<p class="mt-2 text-lg font-semibold text-fg">One session, three catalogs</p>
|
||||
</div>
|
||||
<Gamepad2 class="h-5 w-5 text-muted-fg" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3">
|
||||
<For each={previewRows}>
|
||||
{(row) => (
|
||||
<div class="rounded-2xl bg-surface-container px-5 py-4 transition-all duration-200 hover:bg-surface-high">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">{row.label}</p>
|
||||
<p class="mt-2 text-sm font-semibold text-fg">{row.title}</p>
|
||||
<p class="mt-1 text-sm text-muted-fg">{row.detail}</p>
|
||||
</div>
|
||||
<div
|
||||
class={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl font-display text-lg font-semibold',
|
||||
row.tone === 'accent'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: row.tone === 'secondary'
|
||||
? 'bg-secondary/15 text-secondary'
|
||||
: 'bg-surface-high text-muted-fg',
|
||||
)}
|
||||
>
|
||||
{mediaMonogram(row.title)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel flex items-center justify-center p-8 sm:p-10">
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<Badge variant={isMockApiEnabled() ? 'accent' : 'secondary'}>{sessionLabel()}</Badge>
|
||||
|
||||
<div class="inline-flex rounded-xl bg-surface-container p-1">
|
||||
<For each={themeModes}>
|
||||
{(themeMode) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'rounded-lg px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest transition-all duration-200',
|
||||
theme.mode() === themeMode.value
|
||||
? 'bg-surface-high text-fg'
|
||||
: 'text-muted-fg hover:text-fg',
|
||||
)}
|
||||
onClick={() => theme.setMode(themeMode.value)}
|
||||
>
|
||||
{themeMode.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Access your library</p>
|
||||
<h2 class="mt-4 text-4xl font-semibold text-fg leading-tight">
|
||||
{mode() === 'login' ? 'Enter Seen.' : 'Create your local session.'}
|
||||
</h2>
|
||||
<p class="mt-4 text-sm text-muted-fg leading-relaxed">{sessionSummary()}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<div class="rounded-xl bg-surface-low p-1.5">
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<For each={formModes}>
|
||||
{(formMode) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'rounded-lg px-4 py-3 text-sm font-semibold transition-all duration-200',
|
||||
mode() === formMode.value
|
||||
? 'bg-surface-container text-fg'
|
||||
: 'text-muted-fg hover:text-fg',
|
||||
)}
|
||||
onClick={() => setMode(formMode.value)}
|
||||
>
|
||||
{formMode.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="space-y-5" disabled={auth.isBusy()}>
|
||||
<label class="block space-y-2.5">
|
||||
<span class="text-sm font-medium text-muted-fg">Email</span>
|
||||
<Input
|
||||
aria-label="Email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
autocapitalize="none"
|
||||
placeholder="you@seen.local"
|
||||
spellcheck={false}
|
||||
value={email()}
|
||||
onInput={(event) => setEmail(event.currentTarget.value)}
|
||||
class="h-12"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Show when={mode() === 'register'}>
|
||||
<label class="block space-y-2.5">
|
||||
<span class="text-sm font-medium text-muted-fg">Display Name</span>
|
||||
<Input
|
||||
aria-label="Display Name"
|
||||
autocomplete="name"
|
||||
placeholder="Movie Night Operator"
|
||||
value={displayName()}
|
||||
onInput={(event) => setDisplayName(event.currentTarget.value)}
|
||||
class="h-12"
|
||||
/>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<label class="block space-y-2.5">
|
||||
<span class="text-sm font-medium text-muted-fg">Password</span>
|
||||
<Input
|
||||
aria-label="Password"
|
||||
type="password"
|
||||
autocomplete={mode() === 'register' ? 'new-password' : 'current-password'}
|
||||
placeholder="Enter your password"
|
||||
value={password()}
|
||||
onInput={(event) => setPassword(event.currentTarget.value)}
|
||||
class="h-12"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="rounded-2xl bg-surface-low p-5">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Session details</p>
|
||||
<p class="mt-2 text-sm text-fg leading-relaxed">{sessionDetails()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="rounded-2xl bg-danger/15 px-5 py-4 text-sm text-danger">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Button type="submit" class="h-12 w-full justify-between px-5 text-base">
|
||||
<span>
|
||||
{auth.isBusy()
|
||||
? 'Please wait...'
|
||||
: mode() === 'login'
|
||||
? 'Sign In to Dashboard'
|
||||
: 'Create Local Account'}
|
||||
</span>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 text-xs text-muted-fg">
|
||||
<p>Private by default. Your library stays attached to your own stack.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="font-semibold text-fg transition-colors hover:text-primary"
|
||||
onClick={() => setMode(mode() === 'login' ? 'register' : 'login')}
|
||||
>
|
||||
{mode() === 'login' ? 'Need an account?' : 'Already have one?'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Clapperboard, Clock3, Flame, Star } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { discoverService } from '@/services/discover-service'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const moviesOnly = (items: MediaItem[]) => items.filter((item) => item.type === 'movie')
|
||||
|
||||
export const MoviesPage = () => {
|
||||
const [discoverData, { refetch }] = createResource(() => discoverService.getSections({ page: 1, pageSize: 5 }))
|
||||
const allSections = createMemo(() => discoverData() ?? [])
|
||||
const trendingMovies = createMemo(() =>
|
||||
moviesOnly(allSections().find((section) => section.kind === 'trending')?.items ?? []),
|
||||
)
|
||||
const topRatedMovies = createMemo(() =>
|
||||
moviesOnly(allSections().find((section) => section.kind === 'top-rated')?.items ?? []),
|
||||
)
|
||||
const upcomingMovies = createMemo(() =>
|
||||
moviesOnly(allSections().find((section) => section.kind === 'upcoming')?.items ?? []),
|
||||
)
|
||||
|
||||
const averageRating = createMemo(() => {
|
||||
const items = topRatedMovies()
|
||||
if (items.length === 0) return 0
|
||||
return (items.reduce((acc, item) => acc + item.rating, 0) / items.length).toFixed(1)
|
||||
})
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="movies-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Movies"
|
||||
title="A dedicated movie surface with trending, top rated, and upcoming picks."
|
||||
description="Focused on film browsing without mixing in episodic or game noise."
|
||||
badge={<Badge variant="neutral">{trendingMovies().length + topRatedMovies().length} loaded cards</Badge>}
|
||||
action={<Button variant="subtle" size="sm" onClick={() => refetch()}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Trending</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{trendingMovies().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Top rated</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{topRatedMovies().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Upcoming</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{upcomingMovies().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Avg rating</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{averageRating()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={discoverData.loading && allSections().length === 0}>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<For each={Array.from({ length: 6 }, (_, index) => index)}>{() => <Skeleton class="h-64" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_360px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Trending movies"
|
||||
description="Films currently drawing the most attention."
|
||||
badge={<Badge variant="accent">{trendingMovies().length} active</Badge>}
|
||||
/>
|
||||
<Show
|
||||
when={trendingMovies().length > 0}
|
||||
fallback={<EmptyState title="No trending movies" description="Movie feed is currently empty." />}
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={trendingMovies().slice(0, 6)}>{(item) => <MediaCard item={item} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Signals"
|
||||
description="Quick indicators for film curation quality."
|
||||
badge={<Badge variant="secondary">Live</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Momentum"
|
||||
title={`${trendingMovies().length} titles trending`}
|
||||
description="High-interest films currently crossing discovery rails."
|
||||
trailing={<Flame class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Quality"
|
||||
title={`${averageRating()}/10 average top-rated score`}
|
||||
description="Ranking quality based on current top-rated snapshot."
|
||||
trailing={<Star class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Pipeline"
|
||||
title={`${upcomingMovies().length} upcoming releases`}
|
||||
description="Near-term movie launches currently visible in calendar feed."
|
||||
trailing={<Clock3 class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Upcoming releases"
|
||||
description="Movies arriving soon."
|
||||
badge={<Badge variant="neutral">{upcomingMovies().length} upcoming</Badge>}
|
||||
/>
|
||||
<Show
|
||||
when={upcomingMovies().length > 0}
|
||||
fallback={<EmptyState title="No upcoming movies" description="Upcoming movie release feed is empty." />}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={upcomingMovies()}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(item.type)}
|
||||
eyebrow={mediaTypeLabel(item.type)}
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
badges={<Badge variant={mediaBadgeVariant(item.type)}>{mediaTypeLabel(item.type)}</Badge>}
|
||||
trailing={<Clapperboard class="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={discoverData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{discoverData.error instanceof Error ? discoverData.error.message : 'Failed to load movies'}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { routeByPath } from '@/app/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
routePath: string
|
||||
}
|
||||
|
||||
export const PlaceholderPage = (props: PlaceholderPageProps) => {
|
||||
const route = routeByPath(props.routePath)
|
||||
|
||||
return (
|
||||
<section class="space-y-4" data-testid="placeholder-page">
|
||||
<Card class="overflow-hidden">
|
||||
<CardHeader class="items-start">
|
||||
<div>
|
||||
<Badge variant="neutral">Planned surface</Badge>
|
||||
<CardTitle>{route?.label ?? 'Coming Soon'}</CardTitle>
|
||||
<CardDescription>
|
||||
{route?.description ?? 'This area is in active development.'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="rounded-[1.5rem] border border-dashed border-border bg-muted/50 p-6">
|
||||
<p class="text-sm text-muted-fg">
|
||||
This route is scaffolded so navigation and layout stay stable while the rest of the product is
|
||||
being tightened up.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Compass, Sparkles, Wand2 } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { dashboardService } from '@/services/dashboard-service'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const reasonTone = (score: number): 'accent' | 'neutral' | 'secondary' => {
|
||||
if (score >= 90) return 'accent'
|
||||
if (score >= 80) return 'neutral'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export const RecommendationsPage = () => {
|
||||
const [dashboardData, { refetch }] = createResource(() => dashboardService.getDashboard())
|
||||
const recommendations = createMemo(() => dashboardData()?.recommendations ?? [])
|
||||
const topPicks = createMemo(() => recommendations().slice(0, 3))
|
||||
const moreLikeThis = createMemo(() => recommendations().slice(3))
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="recommendations-page">
|
||||
<Card class="animate-stagger overflow-hidden">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Recommendations"
|
||||
title="Personalized picks shaped by your history, queue, and genre signal."
|
||||
description="A compact recommendation lane designed to feel editorial rather than algorithmic noise."
|
||||
badge={<Badge variant="accent">{recommendations().length} ranked picks</Badge>}
|
||||
action={
|
||||
<button
|
||||
class="inline-flex h-8 items-center rounded-lg border border-border px-3 text-sm text-fg transition hover:border-accent/40 hover:text-accent"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-fg">
|
||||
<Sparkles class="h-4 w-4 text-accent" />
|
||||
Match quality
|
||||
</div>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">
|
||||
{recommendations().length > 0
|
||||
? `${Math.round(
|
||||
recommendations().reduce((acc, item) => acc + item.score, 0) / recommendations().length,
|
||||
)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-fg">
|
||||
<Compass class="h-4 w-4 text-accent" />
|
||||
Distinct genres
|
||||
</div>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">
|
||||
{
|
||||
new Set(recommendations().flatMap((item) => item.media.genres.map((genre) => genre.toLowerCase())))
|
||||
.size
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-fg">
|
||||
<Wand2 class="h-4 w-4 text-accent" />
|
||||
High confidence
|
||||
</div>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">
|
||||
{recommendations().filter((item) => item.score >= 90).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={dashboardData.loading && recommendations().length === 0}>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<For each={Array.from({ length: 3 }, (_, index) => index)}>{() => <Skeleton class="h-64" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={recommendations().length > 0}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent class="pt-6">
|
||||
<EmptyState
|
||||
title="No recommendations yet"
|
||||
description="Start watching and adding titles to watch later to generate better signal."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<SectionHeading title="Top picks" description="Highest confidence matches based on your profile." />
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<For each={topPicks()}>
|
||||
{(entry) => (
|
||||
<div class="space-y-2">
|
||||
<MediaCard item={entry.media} />
|
||||
<DataRow
|
||||
tone={reasonTone(entry.score)}
|
||||
eyebrow={`Match ${entry.score}%`}
|
||||
title={entry.media.title}
|
||||
description={entry.reason}
|
||||
meta={`${mediaTypeLabel(entry.media.type)} · ${entry.media.genres.slice(0, 2).join(' • ')}`}
|
||||
badges={<Badge variant={mediaBadgeVariant(entry.media.type)}>{mediaTypeLabel(entry.media.type)}</Badge>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<SectionHeading title="More like this" description="Additional recommendations with lower but relevant confidence." />
|
||||
<div class="space-y-3">
|
||||
<For each={moreLikeThis()}>
|
||||
{(entry) => (
|
||||
<DataRow
|
||||
tone={reasonTone(entry.score)}
|
||||
eyebrow={`Match ${entry.score}%`}
|
||||
title={entry.media.title}
|
||||
description={entry.reason}
|
||||
meta={`${mediaTypeLabel(entry.media.type)} · ${entry.media.genres.slice(0, 2).join(' • ')}`}
|
||||
badges={<Badge variant={mediaBadgeVariant(entry.media.type)}>{mediaTypeLabel(entry.media.type)}</Badge>}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={dashboardData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{dashboardData.error instanceof Error ? dashboardData.error.message : 'Failed to load recommendations'}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Bell, Monitor, Palette, ShieldCheck, UserRound } from 'lucide-solid'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import { useTheme } from '@/stores/theme-store'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const auth = useAuth()
|
||||
const theme = useTheme()
|
||||
const [displayName, setDisplayName] = createSignal(auth.user()?.displayName ?? 'Movie Night Operator')
|
||||
const [email, setEmail] = createSignal(auth.user()?.email ?? 'you@seen.local')
|
||||
const [saved, setSaved] = createSignal(false)
|
||||
|
||||
const saveProfile = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="settings-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Settings"
|
||||
title="Profile, interface, and security controls for your personal control center."
|
||||
description="Core preferences are kept compact and easy to audit."
|
||||
badge={<Badge variant="neutral">User preferences</Badge>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Theme</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg capitalize">{theme.mode()}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Role</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg capitalize">{auth.user()?.role ?? 'user'}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Security</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">Stable</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Session</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_360px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Profile"
|
||||
description="Update the identity shown across activity and sharing surfaces."
|
||||
badge={<Badge variant="accent">Editable</Badge>}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-muted-fg">Display name</label>
|
||||
<Input value={displayName()} onInput={(event) => setDisplayName(event.currentTarget.value)} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-muted-fg">Email</label>
|
||||
<Input value={email()} onInput={(event) => setEmail(event.currentTarget.value)} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" onClick={saveProfile}>
|
||||
Save profile
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary">
|
||||
Reset
|
||||
</Button>
|
||||
{saved() ? <span class="text-xs text-accent">Saved</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Preferences"
|
||||
description="Theme and surface behavior controls."
|
||||
badge={<Badge variant="secondary">Runtime</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Theme mode"
|
||||
title="Appearance"
|
||||
description="Dark mode is primary, light/system remain available."
|
||||
meta={`Current: ${theme.mode()}`}
|
||||
trailing={<Palette class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Playback"
|
||||
title="Default behavior"
|
||||
description="Resume and autoplay preferences can be surfaced here."
|
||||
trailing={<Monitor class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Notifications"
|
||||
title="Release and download alerts"
|
||||
description="Notification channels can be connected as backend support expands."
|
||||
trailing={<Bell class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Security"
|
||||
description="Account and session posture."
|
||||
badge={<Badge variant="neutral">Read model</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Authentication"
|
||||
title="Session token strategy active"
|
||||
description="Access token + refresh flow is currently enabled."
|
||||
trailing={<ShieldCheck class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Identity"
|
||||
title={displayName()}
|
||||
description={email()}
|
||||
trailing={<UserRound class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CalendarClock, Layers3, PlayCircle, Tv } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { discoverService } from '@/services/discover-service'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
const showsOnly = (items: MediaItem[]) => items.filter((item) => item.type === 'show')
|
||||
|
||||
export const ShowsPage = () => {
|
||||
const [discoverData, { refetch }] = createResource(() => discoverService.getSections({ page: 1, pageSize: 6 }))
|
||||
const allSections = createMemo(() => discoverData() ?? [])
|
||||
const airingToday = createMemo(() => showsOnly(allSections().find((section) => section.kind === 'airing-today')?.items ?? []))
|
||||
const popularShows = createMemo(() => showsOnly(allSections().find((section) => section.kind === 'popular')?.items ?? []))
|
||||
const trendingShows = createMemo(() => showsOnly(allSections().find((section) => section.kind === 'trending')?.items ?? []))
|
||||
|
||||
const averageRuntime = createMemo(() => {
|
||||
const items = [...airingToday(), ...popularShows()]
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((acc, item) => acc + item.runtimeMinutes, 0) / items.length)
|
||||
})
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="shows-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Shows"
|
||||
title="Episode-first surface for ongoing series and fresh drops."
|
||||
description="Built for tracking what is airing, what is trending, and what deserves your next session."
|
||||
badge={<Badge variant="neutral">{airingToday().length} airing now</Badge>}
|
||||
action={<Button variant="subtle" size="sm" onClick={() => refetch()}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Airing today</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{airingToday().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Popular</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{popularShows().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Trending</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{trendingShows().length}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-muted-fg">Avg runtime</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-fg">{averageRuntime()}m</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={discoverData.loading && allSections().length === 0}>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<For each={Array.from({ length: 6 }, (_, index) => index)}>{() => <Skeleton class="h-64" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_360px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Airing today"
|
||||
description="Series episodes available now."
|
||||
badge={<Badge variant="accent">{airingToday().length} episodes</Badge>}
|
||||
/>
|
||||
<Show
|
||||
when={airingToday().length > 0}
|
||||
fallback={<EmptyState title="No shows airing today" description="Airing schedule is currently quiet." />}
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={airingToday().slice(0, 6)}>{(item) => <MediaCard item={item} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Signals"
|
||||
description="Operational show-feed indicators."
|
||||
badge={<Badge variant="secondary">Live</Badge>}
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<DataRow
|
||||
tone="accent"
|
||||
eyebrow="Current feed"
|
||||
title={`${airingToday().length} titles live today`}
|
||||
description="Airing data synced into the discover rail."
|
||||
trailing={<PlayCircle class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="neutral"
|
||||
eyebrow="Catalog depth"
|
||||
title={`${popularShows().length + trendingShows().length} show cards loaded`}
|
||||
description="Popular and trending cards available for quick browse."
|
||||
trailing={<Layers3 class="h-4 w-4" />}
|
||||
/>
|
||||
<DataRow
|
||||
tone="secondary"
|
||||
eyebrow="Runtime profile"
|
||||
title={`${averageRuntime()}m average runtime`}
|
||||
description="Useful for planning next watch session length."
|
||||
trailing={<CalendarClock class="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Popular shows"
|
||||
description="High-interest series with stable engagement."
|
||||
badge={<Badge variant="neutral">{popularShows().length} popular</Badge>}
|
||||
/>
|
||||
<Show
|
||||
when={popularShows().length > 0}
|
||||
fallback={<EmptyState title="No popular shows" description="Popular show feed is empty." />}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={popularShows()}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(item.type)}
|
||||
eyebrow={mediaTypeLabel(item.type)}
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
badges={<Badge variant={mediaBadgeVariant(item.type)}>{mediaTypeLabel(item.type)}</Badge>}
|
||||
trailing={<Tv class="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={discoverData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{discoverData.error instanceof Error ? discoverData.error.message : 'Failed to load shows'}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Clock3, Film, Gamepad2, ListVideo, Search, Tv } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal, type Component } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
import { searchService } from '@/services/search-service'
|
||||
import { watchLaterService } from '@/services/watch-later-service'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import type { MediaType, SearchResult } from '@/types/domain'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
type QueueScope = 'all' | MediaType
|
||||
type IconComponent = Component<{ class?: string; size?: number | string }>
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'movie', label: 'Movies' },
|
||||
{ value: 'show', label: 'Shows' },
|
||||
{ value: 'game', label: 'Games' },
|
||||
] as const
|
||||
|
||||
const mediaIcon = (value: MediaType): IconComponent =>
|
||||
value === 'movie' ? Film : value === 'show' ? Tv : Gamepad2
|
||||
|
||||
const SearchResultRow = (props: {
|
||||
result: SearchResult
|
||||
queued: boolean
|
||||
busy: boolean
|
||||
onToggle: () => void
|
||||
}) => (
|
||||
<DataRow
|
||||
tone={props.queued ? 'secondary' : 'accent'}
|
||||
eyebrow={`Score ${props.result.score}`}
|
||||
title={props.result.title}
|
||||
description={props.result.subtitle}
|
||||
badges={<Badge variant={mediaBadgeVariant(props.result.mediaType)}>{mediaTypeLabel(props.result.mediaType)}</Badge>}
|
||||
trailing={
|
||||
<Button size="sm" variant={props.queued ? 'secondary' : 'primary'} disabled={props.busy} onClick={props.onToggle}>
|
||||
{props.busy ? 'Saving…' : props.queued ? 'Remove' : 'Queue'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
const queueScopeLabel = (value: QueueScope): string =>
|
||||
value === 'all' ? 'all media' : mediaTypeLabel(value).toLowerCase()
|
||||
|
||||
export const WatchLaterPage = () => {
|
||||
const auth = useAuth()
|
||||
const [reloadToken, setReloadToken] = createSignal(0)
|
||||
const [query, setQuery] = createSignal('')
|
||||
const [scope, setScope] = createSignal<QueueScope>('all')
|
||||
const [busyMediaId, setBusyMediaId] = createSignal<number | null>(null)
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
|
||||
const [watchLaterData, { refetch: refetchQueue }] = createResource(reloadToken, async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return []
|
||||
}
|
||||
|
||||
return watchLaterService.getWatchLater(accessToken)
|
||||
})
|
||||
|
||||
const [searchResults] = createResource(
|
||||
() => [query().trim(), scope()] as const,
|
||||
async ([term, currentScope]) => {
|
||||
if (term.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
return searchService.search(term, { mediaType: currentScope })
|
||||
},
|
||||
)
|
||||
|
||||
const queueItems = createMemo(() => watchLaterData() ?? [])
|
||||
const queueIds = createMemo(() => new Set(queueItems().map((item) => item.id)))
|
||||
const filteredQueueItems = createMemo(() =>
|
||||
scope() === 'all' ? queueItems() : queueItems().filter((item) => item.type === scope()),
|
||||
)
|
||||
const scopeCounts = createMemo(() => ({
|
||||
movie: queueItems().filter((item) => item.type === 'movie').length,
|
||||
show: queueItems().filter((item) => item.type === 'show').length,
|
||||
game: queueItems().filter((item) => item.type === 'game').length,
|
||||
}))
|
||||
const queueHours = createMemo(() =>
|
||||
Math.round(queueItems().reduce((total, item) => total + item.runtimeMinutes, 0) / 60),
|
||||
)
|
||||
const queryActive = createMemo(() => query().trim().length >= 2)
|
||||
const groupedQueue = createMemo(() =>
|
||||
(['movie', 'show', 'game'] as const)
|
||||
.map((type) => ({
|
||||
type,
|
||||
items: queueItems().filter((item) => item.type === type),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
const refresh = (): void => {
|
||||
setReloadToken((value) => value + 1)
|
||||
}
|
||||
|
||||
const toggleQueue = async (mediaId: number): Promise<void> => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
setActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
setActionError(null)
|
||||
setBusyMediaId(mediaId)
|
||||
|
||||
try {
|
||||
if (queueIds().has(mediaId)) {
|
||||
await watchLaterService.removeWatchLater(accessToken, mediaId)
|
||||
} else {
|
||||
await watchLaterService.addWatchLater(accessToken, mediaId)
|
||||
}
|
||||
|
||||
refresh()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to update the queue'
|
||||
setActionError(message)
|
||||
} finally {
|
||||
setBusyMediaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="watch-later-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Queue"
|
||||
title="Keep the next few titles close and drop the rest of the noise."
|
||||
description="This queue is shared across movies, shows, and games, but the surface stays compact."
|
||||
badge={<Badge variant="neutral">{queueItems().length} saved</Badge>}
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-fg" />
|
||||
<Input
|
||||
aria-label="Queue search"
|
||||
placeholder="Search the catalog and save titles directly into the queue"
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
class="h-12 pl-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
label="Queue scope"
|
||||
value={scope()}
|
||||
onChange={(value) => setScope(value as QueueScope)}
|
||||
options={scopeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[1.5rem] border border-border bg-muted/45 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-muted-fg">Queue summary</p>
|
||||
<p class="mt-2 text-sm text-fg">
|
||||
{filteredQueueItems().length > 0
|
||||
? `${filteredQueueItems().length} ${scope() === 'all' ? 'titles' : queueScopeLabel(scope())} visible right now.`
|
||||
: 'Nothing in the current scope yet.'}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted-fg">
|
||||
Total queue depth is about {queueItems().length > 0 ? `${Math.max(1, queueHours())} hours` : '0 hours'}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={ListVideo}
|
||||
label="Saved now"
|
||||
value={`${queueItems().length}`}
|
||||
detail="titles in the queue"
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Clock3}
|
||||
label="Depth"
|
||||
value={queueItems().length > 0 ? `${Math.max(1, queueHours())}h` : '0h'}
|
||||
detail="estimated queue time"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Film}
|
||||
label="Movies"
|
||||
value={`${scopeCounts().movie}`}
|
||||
detail="saved films"
|
||||
tone="secondary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Gamepad2}
|
||||
label="Games"
|
||||
value={`${scopeCounts().game}`}
|
||||
detail="saved games"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={actionError()}>
|
||||
<div class="rounded-[1.4rem] border border-secondary/24 bg-secondary/10 px-4 py-3 text-sm text-fg">
|
||||
{actionError()}
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.04fr)_380px]">
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Saved in Queue"
|
||||
description="The titles currently sitting in this lane."
|
||||
badge={<Badge variant="accent">{filteredQueueItems().length} visible</Badge>}
|
||||
/>
|
||||
|
||||
<Show when={watchLaterData.loading && filteredQueueItems().length === 0}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={Array.from({ length: 4 }, (_, index) => index)}>{() => <Skeleton class="h-[280px]" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={filteredQueueItems().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="Queue is empty"
|
||||
description="Search above to add movies, shows, or games into the shared queue."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={filteredQueueItems().slice(0, 6)}>
|
||||
{(item) => (
|
||||
<div class="space-y-3">
|
||||
<MediaCard item={item} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busyMediaId() === item.id}
|
||||
onClick={() => void toggleQueue(item.id)}
|
||||
>
|
||||
{busyMediaId() === item.id ? 'Saving…' : 'Remove from queue'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Show when={!queryActive()}>
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Queue by Type"
|
||||
description="A compact breakdown of what is actually waiting."
|
||||
badge={<Badge variant="neutral">{groupedQueue().length} active groups</Badge>}
|
||||
/>
|
||||
|
||||
<div class="space-y-5">
|
||||
<For each={groupedQueue()}>
|
||||
{(group) => {
|
||||
const Icon = mediaIcon(group.type)
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-2xl border border-border bg-card text-fg">
|
||||
<Icon class="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-fg">{mediaTypeLabel(group.type)}</p>
|
||||
<p class="text-xs text-muted-fg">{group.items.length} saved</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={group.items.slice(0, 3)}>
|
||||
{(item) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(item.type)}
|
||||
eyebrow={mediaTypeLabel(item.type)}
|
||||
title={item.title}
|
||||
description={item.genres.slice(0, 2).join(' • ')}
|
||||
meta={formatDate(item.releaseDate)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title={queryActive() ? 'Search Results' : 'Search to Add'}
|
||||
description={
|
||||
queryActive()
|
||||
? 'Save new titles directly from search results.'
|
||||
: 'Start typing above to search the full catalog.'
|
||||
}
|
||||
badge={<Badge variant="neutral">{(searchResults() ?? []).length} matches</Badge>}
|
||||
/>
|
||||
|
||||
<Show when={searchResults.loading}>
|
||||
<p class="text-sm text-muted-fg">Searching…</p>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={(searchResults() ?? []).length > 0}
|
||||
fallback={
|
||||
<Show when={!searchResults.loading}>
|
||||
<EmptyState
|
||||
title={queryActive() ? 'Nothing matched' : 'Search is idle'}
|
||||
description={
|
||||
queryActive()
|
||||
? 'Try a broader search term or switch the scope.'
|
||||
: 'Use the search field to bring new titles into the queue.'
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={searchResults()}>
|
||||
{(result) => (
|
||||
<SearchResultRow
|
||||
result={result}
|
||||
queued={queueIds().has(result.id)}
|
||||
busy={busyMediaId() === result.id}
|
||||
onToggle={() => void toggleQueue(result.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={watchLaterData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{watchLaterData.error.message}
|
||||
<Button variant="secondary" size="sm" class="ml-3" onClick={() => refetchQueue()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { BookmarkPlus, Clock3, History, Star } from 'lucide-solid'
|
||||
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
||||
import { MediaCard } from '@/components/media/media-card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataRow } from '@/components/ui/data-row'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { SectionHeading } from '@/components/ui/section-heading'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
import { dashboardService } from '@/services/dashboard-service'
|
||||
import { watchLaterService } from '@/services/watch-later-service'
|
||||
import { useAuth } from '@/stores/auth-store'
|
||||
import type { MediaItem, MediaType } from '@/types/domain'
|
||||
import { formatDate, formatRating } from '@/utils/format'
|
||||
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
||||
|
||||
type HistoryScope = 'all' | MediaType
|
||||
|
||||
interface CompletionEntry {
|
||||
item: MediaItem
|
||||
watchedAt: string
|
||||
personalScore: number
|
||||
summary: string
|
||||
}
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'movie', label: 'Movies' },
|
||||
{ value: 'show', label: 'Shows' },
|
||||
{ value: 'game', label: 'Games' },
|
||||
] as const
|
||||
|
||||
const completionOffsets = [1, 3, 5, 8, 12, 16, 21, 27]
|
||||
const scoreAdjustments = [0.4, 0.1, 0.3, 0.2, -0.1, 0.2, 0.1, 0.4]
|
||||
|
||||
const clampScore = (value: number): number => Math.max(6.5, Math.min(9.9, Number(value.toFixed(1))))
|
||||
|
||||
const completionSummary = (item: MediaItem): string =>
|
||||
item.type === 'movie'
|
||||
? 'Closed cleanly and worth keeping in your replay lane.'
|
||||
: item.type === 'show'
|
||||
? 'Finished a strong run of episodes and filed it for an easy revisit.'
|
||||
: 'Campaign wrapped with enough momentum to stay in the backlog conversation.'
|
||||
|
||||
const relativeTimeLabel = (isoDate: string): string => {
|
||||
const diffMs = Math.max(0, Date.now() - new Date(isoDate).getTime())
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffHours < 24) {
|
||||
return diffHours <= 1 ? '1 hour ago' : `${diffHours} hours ago`
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
|
||||
if (diffDays < 14) {
|
||||
return `${diffDays} days ago`
|
||||
}
|
||||
|
||||
return formatDate(isoDate)
|
||||
}
|
||||
|
||||
const buildCompletionEntries = (items: MediaItem[]): CompletionEntry[] =>
|
||||
items
|
||||
.map((item, index) => {
|
||||
const watchedAt = new Date()
|
||||
watchedAt.setDate(watchedAt.getDate() - (completionOffsets[index] ?? (index + 1) * 4))
|
||||
watchedAt.setHours(item.type === 'game' ? 22 : 21 - (index % 2), 10 + ((index * 11) % 35), 0, 0)
|
||||
|
||||
return {
|
||||
item,
|
||||
watchedAt: watchedAt.toISOString(),
|
||||
personalScore: clampScore(item.rating + (scoreAdjustments[index] ?? 0)),
|
||||
summary: completionSummary(item),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => new Date(right.watchedAt).getTime() - new Date(left.watchedAt).getTime())
|
||||
|
||||
export const WatchedPage = () => {
|
||||
const auth = useAuth()
|
||||
const [scope, setScope] = createSignal<HistoryScope>('all')
|
||||
const [busyMediaId, setBusyMediaId] = createSignal<number | null>(null)
|
||||
const [actionError, setActionError] = createSignal<string | null>(null)
|
||||
const [reloadToken, setReloadToken] = createSignal(0)
|
||||
|
||||
const [dashboardData, { refetch: refetchDashboard }] = createResource(reloadToken, () =>
|
||||
dashboardService.getDashboard(),
|
||||
)
|
||||
|
||||
const [queueData, { refetch: refetchQueue }] = createResource(reloadToken, async () => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return []
|
||||
}
|
||||
|
||||
return watchLaterService.getWatchLater(accessToken)
|
||||
})
|
||||
|
||||
const completionEntries = createMemo(() => buildCompletionEntries(dashboardData()?.recentlyWatched ?? []))
|
||||
const queueIds = createMemo(() => new Set((queueData() ?? []).map((item) => item.id)))
|
||||
const filteredEntries = createMemo(() =>
|
||||
scope() === 'all'
|
||||
? completionEntries()
|
||||
: completionEntries().filter((entry) => entry.item.type === scope()),
|
||||
)
|
||||
const averageScore = createMemo(() => {
|
||||
const entries = filteredEntries()
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatRating(entries.reduce((total, entry) => total + entry.personalScore, 0) / entries.length)
|
||||
})
|
||||
|
||||
const toggleReplayQueue = async (mediaId: number): Promise<void> => {
|
||||
const accessToken = auth.accessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
setActionError('Your session expired. Please sign in again.')
|
||||
return
|
||||
}
|
||||
|
||||
setActionError(null)
|
||||
setBusyMediaId(mediaId)
|
||||
|
||||
try {
|
||||
if (queueIds().has(mediaId)) {
|
||||
await watchLaterService.removeWatchLater(accessToken, mediaId)
|
||||
} else {
|
||||
await watchLaterService.addWatchLater(accessToken, mediaId)
|
||||
}
|
||||
|
||||
setReloadToken((value) => value + 1)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to update replay queue'
|
||||
setActionError(message)
|
||||
} finally {
|
||||
setBusyMediaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="space-y-6" data-testid="watched-page">
|
||||
<Card class="animate-stagger">
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<SectionHeading
|
||||
eyebrow="Watched"
|
||||
title="A clean ledger of what you finished and what deserves a replay."
|
||||
description="History stays readable, with queue actions available only when you need them."
|
||||
badge={<Badge variant="neutral">{filteredEntries().length} entries</Badge>}
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Tabs
|
||||
label="History scope"
|
||||
value={scope()}
|
||||
onChange={(value) => setScope(value as HistoryScope)}
|
||||
options={scopeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={History}
|
||||
label="Completed"
|
||||
value={`${filteredEntries().length}`}
|
||||
detail="logged finishes"
|
||||
tone="accent"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Star}
|
||||
label="Average score"
|
||||
value={averageScore() ? `${averageScore()}` : '0.0'}
|
||||
detail="personal rating"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={BookmarkPlus}
|
||||
label="Queued again"
|
||||
value={`${filteredEntries().filter((entry) => queueIds().has(entry.item.id)).length}`}
|
||||
detail="replay candidates saved"
|
||||
tone="secondary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Clock3}
|
||||
label="Latest finish"
|
||||
value={filteredEntries()[0] ? relativeTimeLabel(filteredEntries()[0]!.watchedAt) : 'No history'}
|
||||
detail="most recent completion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={actionError()}>
|
||||
<div class="rounded-[1.4rem] border border-secondary/24 bg-secondary/10 px-4 py-3 text-sm text-fg">
|
||||
{actionError()}
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="History"
|
||||
description="The most recent finishes across your selected scope."
|
||||
badge={<Badge variant="accent">{filteredEntries().length} logged</Badge>}
|
||||
/>
|
||||
|
||||
<Show when={dashboardData.loading && filteredEntries().length === 0}>
|
||||
<div class="space-y-3">
|
||||
<For each={Array.from({ length: 4 }, (_, index) => index)}>{() => <Skeleton class="h-24" />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={filteredEntries().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No history yet"
|
||||
description="Finished titles will appear here once your activity starts flowing."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={filteredEntries()}>
|
||||
{(entry) => (
|
||||
<DataRow
|
||||
tone={mediaBadgeVariant(entry.item.type)}
|
||||
eyebrow={relativeTimeLabel(entry.watchedAt)}
|
||||
title={entry.item.title}
|
||||
description={entry.summary}
|
||||
meta={`${formatDate(entry.watchedAt)} · Personal score ${formatRating(entry.personalScore)}`}
|
||||
badges={
|
||||
<Badge variant={mediaBadgeVariant(entry.item.type)}>
|
||||
{mediaTypeLabel(entry.item.type)}
|
||||
</Badge>
|
||||
}
|
||||
trailing={
|
||||
<Button
|
||||
size="sm"
|
||||
variant={queueIds().has(entry.item.id) ? 'secondary' : 'primary'}
|
||||
disabled={busyMediaId() === entry.item.id}
|
||||
onClick={() => void toggleReplayQueue(entry.item.id)}
|
||||
>
|
||||
{busyMediaId() === entry.item.id
|
||||
? 'Saving…'
|
||||
: queueIds().has(entry.item.id)
|
||||
? 'Queued'
|
||||
: 'Replay'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="space-y-5 pt-6">
|
||||
<SectionHeading
|
||||
title="Replay Candidates"
|
||||
description="Strong recent finishes you might want to pull back into the queue."
|
||||
badge={<Badge variant="secondary">{filteredEntries().slice(0, 3).length} surfaced</Badge>}
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={filteredEntries().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No replay candidates"
|
||||
description="Replay suggestions will show up after you finish a few more titles."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<For each={filteredEntries().slice(0, 3)}>
|
||||
{(entry) => (
|
||||
<div class="space-y-3">
|
||||
<MediaCard item={entry.item} subtitle={entry.summary} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={queueIds().has(entry.item.id) ? 'secondary' : 'primary'}
|
||||
disabled={busyMediaId() === entry.item.id}
|
||||
onClick={() => void toggleReplayQueue(entry.item.id)}
|
||||
>
|
||||
{busyMediaId() === entry.item.id
|
||||
? 'Saving…'
|
||||
: queueIds().has(entry.item.id)
|
||||
? 'Remove replay'
|
||||
: 'Queue replay'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Show when={dashboardData.error || queueData.error}>
|
||||
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
||||
{dashboardData.error?.message ?? queueData.error?.message ?? 'Unable to load watched history.'}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="ml-3"
|
||||
onClick={() => {
|
||||
refetchDashboard()
|
||||
refetchQueue()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
export interface ServiceRequestOptions {
|
||||
simulateError?: boolean
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
const toNumber = (raw: string | undefined, fallback: number): number => {
|
||||
if (!raw) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
const DEFAULT_LATENCY_MS = toNumber(import.meta.env.VITE_MOCK_API_LATENCY_MS, 260)
|
||||
const MOCK_FORCE_ERROR = import.meta.env.VITE_MOCK_FORCE_ERROR === 'true'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
|
||||
const deepClone = <T>(payload: T): T => {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(payload)
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(payload)) as T
|
||||
}
|
||||
|
||||
const wait = async (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
const toApiError = (status: number, detail: string): Error => {
|
||||
if (detail) {
|
||||
return new Error(detail)
|
||||
}
|
||||
|
||||
return new Error(`Request failed with status ${status}`)
|
||||
}
|
||||
|
||||
const toURL = (path: string): string => {
|
||||
const normalizedBase = API_BASE_URL.replace(/\/$/, '')
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
|
||||
return `${normalizedBase}${normalizedPath}`
|
||||
}
|
||||
|
||||
export const isMockApiEnabled = (): boolean => import.meta.env.VITE_ENABLE_MOCK_API !== 'false'
|
||||
|
||||
const apiRequest = async <T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
queryParams: Record<string, string | number | undefined> = {},
|
||||
): Promise<T> => {
|
||||
const url = new URL(toURL(path), window.location.origin)
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (value === undefined || value === '') {
|
||||
continue
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), init)
|
||||
|
||||
if (!response.ok) {
|
||||
let message = ''
|
||||
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
message = body.error ?? ''
|
||||
} catch {
|
||||
message = ''
|
||||
}
|
||||
|
||||
throw toApiError(response.status, message)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const apiGet = async <T>(
|
||||
path: string,
|
||||
queryParams: Record<string, string | number | undefined> = {},
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<T> =>
|
||||
apiRequest<T>(
|
||||
path,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
queryParams,
|
||||
)
|
||||
|
||||
export const apiPost = async <T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<T> =>
|
||||
apiRequest<T>(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const apiDelete = async <T>(
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<T> =>
|
||||
apiRequest<T>(path, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
|
||||
export const mockResponse = async <T>(
|
||||
requestName: string,
|
||||
producer: () => T,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<T> => {
|
||||
const latency = options.latencyMs ?? DEFAULT_LATENCY_MS
|
||||
await wait(Math.max(0, latency))
|
||||
|
||||
if (!isMockApiEnabled()) {
|
||||
throw new Error(`Mock API is disabled for ${requestName}`)
|
||||
}
|
||||
|
||||
if (MOCK_FORCE_ERROR || options.simulateError) {
|
||||
throw new Error(`Mock request failed for ${requestName}`)
|
||||
}
|
||||
|
||||
return deepClone(producer())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { apiGet, apiPost, isMockApiEnabled, mockResponse } from '@/services/api-client'
|
||||
import type { AuthResult, AuthUser } from '@/types/domain'
|
||||
|
||||
const nowISO = (): string => new Date().toISOString()
|
||||
|
||||
const randomID = (): string =>
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Math.random().toString(36).slice(2)}-${Date.now()}`
|
||||
|
||||
const createMockUser = (email: string, displayName?: string): AuthUser => ({
|
||||
id: randomID(),
|
||||
email,
|
||||
displayName: displayName?.trim() || email.split('@')[0] || 'Seen User',
|
||||
role: 'user',
|
||||
createdAt: nowISO(),
|
||||
updatedAt: nowISO(),
|
||||
})
|
||||
|
||||
const createMockAuthResult = (email: string, displayName?: string): AuthResult => ({
|
||||
accessToken: `mock-access-${randomID()}`,
|
||||
refreshToken: `mock-refresh-${randomID()}`,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
||||
user: createMockUser(email, displayName),
|
||||
})
|
||||
|
||||
const validateCredentials = (email: string, password: string): void => {
|
||||
if (!email.includes('@') || password.trim().length < 8) {
|
||||
throw new Error('invalid credentials format')
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
register: async (input: {
|
||||
email: string
|
||||
password: string
|
||||
displayName?: string
|
||||
}): Promise<AuthResult> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/auth/register', () => {
|
||||
validateCredentials(input.email, input.password)
|
||||
return createMockAuthResult(input.email, input.displayName)
|
||||
})
|
||||
}
|
||||
|
||||
return apiPost<AuthResult>('/api/v1/auth/register', input)
|
||||
},
|
||||
|
||||
login: async (input: { email: string; password: string }): Promise<AuthResult> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/auth/login', () => {
|
||||
validateCredentials(input.email, input.password)
|
||||
return createMockAuthResult(input.email)
|
||||
})
|
||||
}
|
||||
|
||||
return apiPost<AuthResult>('/api/v1/auth/login', input)
|
||||
},
|
||||
|
||||
refresh: async (input: { refreshToken: string }): Promise<AuthResult> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/auth/refresh', () => {
|
||||
if (!input.refreshToken) {
|
||||
throw new Error('missing refresh token')
|
||||
}
|
||||
|
||||
return createMockAuthResult('demo@seen.local', 'Demo User')
|
||||
})
|
||||
}
|
||||
|
||||
return apiPost<AuthResult>('/api/v1/auth/refresh', input)
|
||||
},
|
||||
|
||||
me: async (accessToken: string): Promise<AuthUser> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/auth/me', () => ({
|
||||
id: 'mock-user-id',
|
||||
email: 'demo@seen.local',
|
||||
displayName: 'Demo User',
|
||||
role: 'user',
|
||||
createdAt: nowISO(),
|
||||
updatedAt: nowISO(),
|
||||
}))
|
||||
}
|
||||
|
||||
return apiGet<AuthUser>('/api/v1/auth/me', {}, { Authorization: `Bearer ${accessToken}` })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { dashboardPayload } from '@/services/mock-data'
|
||||
import { apiGet, isMockApiEnabled, mockResponse, type ServiceRequestOptions } from '@/services/api-client'
|
||||
import type { DashboardPayload } from '@/types/domain'
|
||||
|
||||
export const dashboardService = {
|
||||
getDashboard: async (options: ServiceRequestOptions = {}): Promise<DashboardPayload> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/dashboard', () => dashboardPayload, options)
|
||||
}
|
||||
|
||||
return apiGet<DashboardPayload>('/api/v1/dashboard')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { discoverSections } from '@/services/mock-data'
|
||||
import { apiGet, isMockApiEnabled, mockResponse, type ServiceRequestOptions } from '@/services/api-client'
|
||||
import type { DiscoverParams, DiscoverSection, MediaItem } from '@/types/domain'
|
||||
|
||||
const normalize = (value: string): string => value.trim().toLowerCase()
|
||||
|
||||
const itemMatchesFilters = (item: MediaItem, params: Required<DiscoverParams>): boolean => {
|
||||
if (params.mediaType !== 'all' && item.type !== params.mediaType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (params.genre && !item.genres.includes(params.genre)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!params.query) {
|
||||
return true
|
||||
}
|
||||
|
||||
const term = normalize(params.query)
|
||||
return (
|
||||
normalize(item.title).includes(term) ||
|
||||
normalize(item.overview).includes(term) ||
|
||||
item.genres.some((genre) => normalize(genre).includes(term))
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizeParams = (params: DiscoverParams): Required<DiscoverParams> => ({
|
||||
page: Math.max(1, params.page ?? 1),
|
||||
pageSize: Math.max(1, params.pageSize ?? 6),
|
||||
query: params.query?.trim() ?? '',
|
||||
genre: params.genre ?? '',
|
||||
mediaType: params.mediaType ?? 'all',
|
||||
})
|
||||
|
||||
const sectionPage = (
|
||||
items: MediaItem[],
|
||||
currentPage: number,
|
||||
pageSize: number,
|
||||
): MediaItem[] => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
const end = currentPage * pageSize
|
||||
return items.slice(start, end)
|
||||
}
|
||||
|
||||
const buildSections = (params: Required<DiscoverParams>): DiscoverSection[] =>
|
||||
discoverSections
|
||||
.map((section) => {
|
||||
const filtered = section.items.filter((item) => itemMatchesFilters(item, params))
|
||||
const paged = sectionPage(filtered, params.page, params.pageSize)
|
||||
|
||||
return {
|
||||
...section,
|
||||
title: params.genre ? `${section.title} · ${params.genre}` : section.title,
|
||||
items: paged,
|
||||
}
|
||||
})
|
||||
.filter((section) => section.items.length > 0)
|
||||
|
||||
export const discoverService = {
|
||||
getSections: async (
|
||||
params: DiscoverParams,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<DiscoverSection[]> => {
|
||||
const sanitized = sanitizeParams(params)
|
||||
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/discover', () => buildSections(sanitized), options)
|
||||
}
|
||||
|
||||
return apiGet<DiscoverSection[]>('/api/v1/discover', {
|
||||
page: sanitized.page,
|
||||
pageSize: sanitized.pageSize,
|
||||
query: sanitized.query,
|
||||
genre: sanitized.genre,
|
||||
mediaType: sanitized.mediaType,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { dashboardPayload } from '@/services/mock-data'
|
||||
import { apiDelete, apiGet, apiPost, isMockApiEnabled, mockResponse, type ServiceRequestOptions } from '@/services/api-client'
|
||||
import type { DownloadJob } from '@/types/domain'
|
||||
|
||||
export interface DownloadCreateInput {
|
||||
sourceType: 'magnet' | 'torrent' | 'direct' | 'http'
|
||||
source: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface DownloadListParams {
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface DownloadEvent {
|
||||
id: number
|
||||
jobId: string
|
||||
status: string
|
||||
message: string
|
||||
progressPercent: number
|
||||
payload: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const authHeaders = (accessToken: string): Record<string, string> => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
})
|
||||
|
||||
const normalizeStatus = (status: string): DownloadJob['status'] => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
case 'downloading':
|
||||
case 'stalled':
|
||||
case 'completed':
|
||||
case 'failed':
|
||||
return status
|
||||
case 'preparing':
|
||||
case 'retrying':
|
||||
return 'downloading'
|
||||
case 'cancelled':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'queued'
|
||||
}
|
||||
}
|
||||
|
||||
const toEtaMinutes = (etaSeconds?: number): number => {
|
||||
if (!etaSeconds || etaSeconds <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.ceil(etaSeconds / 60)
|
||||
}
|
||||
|
||||
const toDownloadJob = (value: any): DownloadJob => ({
|
||||
id: String(value.id ?? ''),
|
||||
title: String(value.title ?? value.source ?? 'Download job'),
|
||||
status: normalizeStatus(String(value.status ?? 'queued')),
|
||||
progressPercent: Number(value.progressPercent ?? 0),
|
||||
downloadSpeedMbps: Number(value.downloadSpeedMbps ?? 0),
|
||||
etaMinutes: toEtaMinutes(Number(value.etaSeconds ?? 0)),
|
||||
sourceType: (['magnet', 'torrent', 'direct', 'http'].includes(value.sourceType) ? value.sourceType : 'http') as
|
||||
| 'magnet'
|
||||
| 'torrent'
|
||||
| 'direct'
|
||||
| 'http',
|
||||
})
|
||||
|
||||
const cloneDownloadJob = (job: DownloadJob): DownloadJob => ({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
status: job.status,
|
||||
progressPercent: job.progressPercent,
|
||||
downloadSpeedMbps: job.downloadSpeedMbps,
|
||||
etaMinutes: job.etaMinutes,
|
||||
sourceType: job.sourceType,
|
||||
})
|
||||
|
||||
let mockDownloadJobs = dashboardPayload.activeDownloads.map(cloneDownloadJob)
|
||||
|
||||
export const downloadService = {
|
||||
async list(
|
||||
accessToken: string,
|
||||
params: DownloadListParams = {},
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<DownloadJob[]> {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/downloads',
|
||||
() => {
|
||||
let items = mockDownloadJobs.map(cloneDownloadJob)
|
||||
|
||||
if (params.status) {
|
||||
items = items.filter((job) => job.status === params.status)
|
||||
}
|
||||
|
||||
const offset = Math.max(0, params.offset ?? 0)
|
||||
const limit = typeof params.limit === 'number' ? Math.max(1, params.limit) : items.length
|
||||
return items.slice(offset, offset + limit)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.status) query.set('status', params.status)
|
||||
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
|
||||
if (typeof params.offset === 'number') query.set('offset', String(params.offset))
|
||||
|
||||
const suffix = query.toString().length > 0 ? `?${query.toString()}` : ''
|
||||
const response = await apiGet<any[]>(`/api/v1/downloads${suffix}`, {}, authHeaders(accessToken))
|
||||
return response.map(toDownloadJob)
|
||||
},
|
||||
|
||||
async create(
|
||||
accessToken: string,
|
||||
input: DownloadCreateInput,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<DownloadJob> {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/downloads:create',
|
||||
() => {
|
||||
const nextJob: DownloadJob = {
|
||||
id: `mock-download-${Date.now()}`,
|
||||
title: input.title?.trim() || input.source,
|
||||
status: 'queued',
|
||||
progressPercent: 0,
|
||||
downloadSpeedMbps: 0,
|
||||
etaMinutes: 15,
|
||||
sourceType: input.sourceType,
|
||||
}
|
||||
|
||||
mockDownloadJobs = [nextJob, ...mockDownloadJobs].map(cloneDownloadJob)
|
||||
return cloneDownloadJob(nextJob)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await apiPost<any>('/api/v1/downloads', {
|
||||
sourceType: input.sourceType,
|
||||
source: input.source,
|
||||
title: input.title ?? '',
|
||||
}, authHeaders(accessToken))
|
||||
return toDownloadJob(response)
|
||||
},
|
||||
|
||||
async cancel(accessToken: string, jobID: string, options: ServiceRequestOptions = {}): Promise<DownloadJob> {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
`/api/v1/downloads/${jobID}:cancel`,
|
||||
() => {
|
||||
const existing = mockDownloadJobs.find((job) => job.id === jobID)
|
||||
if (!existing) {
|
||||
throw new Error('download job not found')
|
||||
}
|
||||
|
||||
const cancelled = { ...existing, status: 'failed' as const, downloadSpeedMbps: 0, etaMinutes: 0 }
|
||||
mockDownloadJobs = mockDownloadJobs
|
||||
.map((job) => (job.id === jobID ? cancelled : job))
|
||||
.filter((job) => job.status !== 'failed' || job.id === jobID)
|
||||
|
||||
return cloneDownloadJob(cancelled)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await apiDelete<any>(
|
||||
`/api/v1/downloads/${encodeURIComponent(jobID)}`,
|
||||
authHeaders(accessToken),
|
||||
)
|
||||
return toDownloadJob(response)
|
||||
},
|
||||
|
||||
async events(
|
||||
accessToken: string,
|
||||
jobID: string,
|
||||
after?: string,
|
||||
limit = 50,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<DownloadEvent[]> {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
`/api/v1/downloads/${jobID}/events`,
|
||||
() => {
|
||||
const job = mockDownloadJobs.find((item) => item.id === jobID)
|
||||
if (!job) {
|
||||
throw new Error('download job not found')
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
jobId: jobID,
|
||||
status: job.status,
|
||||
message: `Mock event for ${job.title}`,
|
||||
progressPercent: job.progressPercent,
|
||||
payload: '{}',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
].slice(0, Math.max(1, limit))
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await apiGet<any[]>(
|
||||
`/api/v1/downloads/${encodeURIComponent(jobID)}/events`,
|
||||
{
|
||||
limit,
|
||||
after,
|
||||
},
|
||||
authHeaders(accessToken),
|
||||
)
|
||||
return response.map((item: any) => ({
|
||||
id: Number(item.id ?? 0),
|
||||
jobId: String(item.jobId ?? ''),
|
||||
status: String(item.status ?? ''),
|
||||
message: String(item.message ?? ''),
|
||||
progressPercent: Number(item.progressPercent ?? 0),
|
||||
payload: String(item.payload ?? '{}'),
|
||||
createdAt: String(item.createdAt ?? ''),
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiGet, isMockApiEnabled, type ServiceRequestOptions } from '@/services/api-client'
|
||||
import { discoverService } from '@/services/discover-service'
|
||||
import type { DiscoverParams, DiscoverSection } from '@/types/domain'
|
||||
|
||||
export const gamesService = {
|
||||
getSections: async (
|
||||
params: Omit<DiscoverParams, 'mediaType'>,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<DiscoverSection[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return discoverService.getSections({ ...params, mediaType: 'game' }, options)
|
||||
}
|
||||
|
||||
return apiGet<DiscoverSection[]>('/api/v1/games', {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 6,
|
||||
query: params.query?.trim() ?? '',
|
||||
genre: params.genre ?? '',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
ContinueWatchingItem,
|
||||
DashboardPayload,
|
||||
DiscoverSection,
|
||||
MediaItem,
|
||||
RecommendationItem,
|
||||
} from '@/types/domain'
|
||||
|
||||
const createMedia = (
|
||||
id: number,
|
||||
provider: 'tmdb' | 'igdb',
|
||||
providerId: number,
|
||||
title: string,
|
||||
type: 'movie' | 'show' | 'game',
|
||||
genres: string[],
|
||||
platforms: string[],
|
||||
releaseDate: string,
|
||||
rating: number,
|
||||
runtimeMinutes: number,
|
||||
): MediaItem => ({
|
||||
id,
|
||||
provider,
|
||||
providerId,
|
||||
title,
|
||||
overview:
|
||||
type === 'movie'
|
||||
? `${title} is a cinematic journey with high production value and sharp pacing.`
|
||||
: type === 'show'
|
||||
? `${title} is a serialized story balancing character arcs with premium world-building.`
|
||||
: `${title} is a high-signal game release tracked through your IGDB-powered backlog.`,
|
||||
type,
|
||||
releaseDate,
|
||||
genres,
|
||||
platforms,
|
||||
rating,
|
||||
runtimeMinutes,
|
||||
artworkKey: `${title}-${id}`,
|
||||
})
|
||||
|
||||
const catalog: MediaItem[] = [
|
||||
createMedia(1, 'tmdb', 10001, 'Neon Divide', 'movie', ['Sci-Fi', 'Thriller'], [], '2025-05-14', 8.5, 118),
|
||||
createMedia(2, 'tmdb', 10002, 'Last Light Harbor', 'show', ['Drama', 'Mystery'], [], '2024-09-02', 8.1, 52),
|
||||
createMedia(3, 'tmdb', 10003, 'Orbitline', 'movie', ['Sci-Fi', 'Adventure'], [], '2025-02-21', 7.9, 131),
|
||||
createMedia(4, 'tmdb', 10004, 'Static Bloom', 'show', ['Comedy', 'Drama'], [], '2023-11-12', 7.8, 42),
|
||||
createMedia(5, 'tmdb', 10005, 'Kingdom Ash', 'movie', ['Fantasy', 'Action'], [], '2024-12-08', 8.7, 143),
|
||||
createMedia(6, 'tmdb', 10006, 'Pulse District', 'show', ['Crime', 'Thriller'], [], '2025-03-18', 8.0, 48),
|
||||
createMedia(7, 'tmdb', 10007, 'The Glass Relay', 'movie', ['Action', 'Thriller'], [], '2024-07-01', 7.6, 109),
|
||||
createMedia(8, 'tmdb', 10008, 'Summer in Vanta', 'show', ['Romance', 'Drama'], [], '2025-06-30', 7.5, 44),
|
||||
createMedia(9, 'tmdb', 10009, 'Zero Meridian', 'movie', ['Sci-Fi', 'Action'], [], '2026-01-10', 8.9, 127),
|
||||
createMedia(10, 'tmdb', 10010, 'Northline 13', 'show', ['Mystery', 'Crime'], [], '2025-10-19', 8.3, 50),
|
||||
createMedia(11, 'tmdb', 10011, 'Paper Falcons', 'movie', ['Adventure', 'Family'], [], '2024-03-05', 7.4, 101),
|
||||
createMedia(12, 'tmdb', 10012, 'Hollow Anthem', 'movie', ['Drama', 'Music'], [], '2025-08-22', 8.2, 114),
|
||||
createMedia(13, 'tmdb', 10013, 'Riptide Avenue', 'show', ['Action', 'Drama'], [], '2023-04-09', 7.7, 55),
|
||||
createMedia(14, 'tmdb', 10014, 'Night Air Index', 'movie', ['Mystery', 'Thriller'], [], '2026-04-03', 8.4, 122),
|
||||
createMedia(15, 'tmdb', 10015, 'Shoreline Math', 'show', ['Comedy', 'Family'], [], '2024-06-17', 7.3, 37),
|
||||
createMedia(16, 'tmdb', 10016, 'Arcadia Wire', 'movie', ['Fantasy', 'Drama'], [], '2025-12-02', 8.6, 136),
|
||||
createMedia(17, 'tmdb', 10017, 'Abyss Echo', 'show', ['Sci-Fi', 'Mystery'], [], '2025-01-27', 8.8, 53),
|
||||
createMedia(18, 'tmdb', 10018, 'Delta Murmur', 'movie', ['Horror', 'Thriller'], [], '2024-10-29', 7.2, 96),
|
||||
createMedia(19, 'tmdb', 10019, 'Pine Weather', 'show', ['Drama', 'Romance'], [], '2025-04-11', 7.9, 46),
|
||||
createMedia(20, 'tmdb', 10020, 'Copper Atlas', 'movie', ['Adventure', 'Action'], [], '2025-09-09', 8.0, 111),
|
||||
createMedia(21, 'tmdb', 10021, 'Moonset Terminal', 'movie', ['Sci-Fi', 'Drama'], [], '2026-02-14', 8.4, 124),
|
||||
createMedia(22, 'tmdb', 10022, 'Marble Sea', 'show', ['Fantasy', 'Adventure'], [], '2024-01-22', 7.6, 49),
|
||||
createMedia(23, 'tmdb', 10023, 'Tangent Room', 'movie', ['Mystery', 'Drama'], [], '2025-11-03', 8.1, 119),
|
||||
createMedia(24, 'tmdb', 10024, 'Signal Orchard', 'show', ['Thriller', 'Drama'], [], '2025-07-18', 8.2, 51),
|
||||
createMedia(25, 'igdb', 20025, 'Star Circuit Zero', 'game', ['Action', 'Racing'], ['PC', 'PS5', 'Xbox Series X|S'], '2026-09-18', 8.8, 900),
|
||||
createMedia(26, 'igdb', 20026, 'Verdant Protocol', 'game', ['Strategy', 'Simulation'], ['PC'], '2026-11-06', 8.4, 1260),
|
||||
createMedia(27, 'igdb', 20027, 'Mythic Drift', 'game', ['Racing', 'Adventure'], ['PS5', 'Xbox Series X|S'], '2026-07-24', 8.2, 720),
|
||||
createMedia(28, 'igdb', 20028, 'Ashen Vale', 'game', ['RPG', 'Adventure'], ['PC', 'PS5'], '2026-05-15', 9.0, 1680),
|
||||
createMedia(29, 'igdb', 20029, 'Signal Breaker', 'game', ['Shooter', 'Sci-Fi'], ['PC', 'Xbox Series X|S'], '2026-02-28', 8.1, 840),
|
||||
createMedia(30, 'igdb', 20030, 'Luma Forge', 'game', ['Indie', 'Puzzle'], ['Nintendo Switch', 'PC'], '2026-01-16', 8.3, 360),
|
||||
createMedia(31, 'igdb', 20031, 'Citadel Dawn', 'game', ['Strategy', 'RPG'], ['PC'], '2026-03-05', 8.6, 1500),
|
||||
createMedia(32, 'igdb', 20032, 'Harbor Tactics', 'game', ['Strategy', 'Simulation'], ['PC'], '2025-11-21', 7.8, 1080),
|
||||
createMedia(33, 'igdb', 20033, 'Ghostline Kyoto', 'game', ['Action', 'Adventure'], ['PS5', 'PC'], '2026-02-14', 8.7, 1020),
|
||||
createMedia(34, 'igdb', 20034, 'Snowfall County', 'game', ['Simulation', 'Adventure'], ['Nintendo Switch', 'PC'], '2025-12-12', 7.9, 540),
|
||||
createMedia(35, 'igdb', 20035, 'Titan Relay', 'game', ['Shooter', 'Action'], ['PC', 'PS5'], '2026-04-22', 8.5, 780),
|
||||
createMedia(36, 'igdb', 20036, 'Wild Circuit Stories', 'game', ['Racing', 'Indie'], ['Nintendo Switch', 'Xbox Series X|S'], '2026-08-07', 8.0, 420),
|
||||
]
|
||||
|
||||
export const genres = [
|
||||
'Sci-Fi',
|
||||
'Drama',
|
||||
'Thriller',
|
||||
'Action',
|
||||
'Fantasy',
|
||||
'Adventure',
|
||||
'Mystery',
|
||||
'Comedy',
|
||||
'Romance',
|
||||
'Crime',
|
||||
'Family',
|
||||
'Music',
|
||||
'Horror',
|
||||
'Indie',
|
||||
'Puzzle',
|
||||
'RPG',
|
||||
'Racing',
|
||||
'Shooter',
|
||||
'Simulation',
|
||||
'Strategy',
|
||||
]
|
||||
|
||||
const recommendationFor = (media: MediaItem, reason: string, score: number): RecommendationItem => ({
|
||||
id: media.id,
|
||||
media,
|
||||
reason,
|
||||
score,
|
||||
})
|
||||
|
||||
const byId = (id: number): MediaItem => {
|
||||
const found = catalog.find((item) => item.id === id)
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`Media item with id ${id} was not found in mock catalog`)
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
export const continueWatchingItems: ContinueWatchingItem[] = [
|
||||
{
|
||||
item: byId(2),
|
||||
progress: {
|
||||
itemId: 2,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 7,
|
||||
progressPercent: 63,
|
||||
lastWatchedAt: '2026-03-09T21:40:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
item: byId(17),
|
||||
progress: {
|
||||
itemId: 17,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 2,
|
||||
progressPercent: 28,
|
||||
lastWatchedAt: '2026-03-08T23:16:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
item: byId(6),
|
||||
progress: {
|
||||
itemId: 6,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 11,
|
||||
progressPercent: 82,
|
||||
lastWatchedAt: '2026-03-07T18:10:00Z',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const dashboardPayload: DashboardPayload = {
|
||||
watchLater: [byId(25), byId(9), byId(14), byId(33), byId(21), byId(28)],
|
||||
gameBacklog: [byId(25), byId(33), byId(28), byId(31)],
|
||||
activeDownloads: [
|
||||
{
|
||||
id: 'dl-1024',
|
||||
title: 'Zero Meridian (2160p HDR)',
|
||||
status: 'downloading',
|
||||
progressPercent: 47,
|
||||
downloadSpeedMbps: 23.8,
|
||||
etaMinutes: 19,
|
||||
sourceType: 'magnet',
|
||||
},
|
||||
{
|
||||
id: 'dl-1025',
|
||||
title: 'Star Circuit Zero preload',
|
||||
status: 'queued',
|
||||
progressPercent: 0,
|
||||
downloadSpeedMbps: 0,
|
||||
etaMinutes: 34,
|
||||
sourceType: 'http',
|
||||
},
|
||||
{
|
||||
id: 'dl-1026',
|
||||
title: 'Arcadia Wire (1080p)',
|
||||
status: 'stalled',
|
||||
progressPercent: 74,
|
||||
downloadSpeedMbps: 0.2,
|
||||
etaMinutes: 120,
|
||||
sourceType: 'torrent',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
recommendationFor(byId(28), 'Because your queue trends toward expansive fantasy worlds', 93),
|
||||
recommendationFor(byId(24), 'Because you finish serial thrillers quickly', 89),
|
||||
recommendationFor(byId(33), 'Because cinematic action games align with your recent picks', 91),
|
||||
recommendationFor(byId(1), 'Because your recent watches trend sci-fi', 88),
|
||||
],
|
||||
trending: [byId(9), byId(25), byId(33), byId(21), byId(16), byId(14)],
|
||||
upcoming: [byId(21), byId(25), byId(26), byId(14), byId(35)],
|
||||
recentlyWatched: [byId(2), byId(6), byId(17), byId(33), byId(29)],
|
||||
}
|
||||
|
||||
export const discoverSections: DiscoverSection[] = [
|
||||
{
|
||||
kind: 'trending',
|
||||
title: 'Trending',
|
||||
subtitle: 'Hot picks across screens and launchers',
|
||||
items: [byId(9), byId(25), byId(33), byId(21), byId(6), byId(17), byId(28), byId(23)],
|
||||
},
|
||||
{
|
||||
kind: 'popular',
|
||||
title: 'Popular',
|
||||
subtitle: 'High-signal releases people keep returning to',
|
||||
items: [byId(1), byId(2), byId(33), byId(5), byId(28), byId(8), byId(10), byId(31)],
|
||||
},
|
||||
{
|
||||
kind: 'top-rated',
|
||||
title: 'Top Rated',
|
||||
subtitle: 'Highest community ratings across all media',
|
||||
items: [byId(9), byId(17), byId(25), byId(5), byId(33), byId(16), byId(28), byId(21)],
|
||||
},
|
||||
{
|
||||
kind: 'upcoming',
|
||||
title: 'Upcoming',
|
||||
subtitle: 'Near-term drops on your radar',
|
||||
items: [byId(21), byId(25), byId(26), byId(14), byId(27), byId(23), byId(35), byId(36)],
|
||||
},
|
||||
{
|
||||
kind: 'now-playing',
|
||||
title: 'Now Playing',
|
||||
subtitle: 'Freshly added movies, episodes, and game launches',
|
||||
items: [byId(3), byId(6), byId(12), byId(29), byId(33), byId(30), byId(2), byId(17)],
|
||||
},
|
||||
{
|
||||
kind: 'airing-today',
|
||||
title: 'Airing Today',
|
||||
subtitle: 'Episodes and drops available now',
|
||||
items: [byId(6), byId(10), byId(17), byId(19), byId(24), byId(22), byId(4), byId(15)],
|
||||
},
|
||||
{
|
||||
kind: 'recently-released-games',
|
||||
title: 'Recently Released Games',
|
||||
subtitle: 'New launches worth checking this month',
|
||||
items: [byId(33), byId(31), byId(29), byId(30), byId(34), byId(32)],
|
||||
},
|
||||
{
|
||||
kind: 'most-anticipated-games',
|
||||
title: 'Most Anticipated Games',
|
||||
subtitle: 'Upcoming releases with strong momentum',
|
||||
items: [byId(25), byId(26), byId(27), byId(28), byId(35), byId(36), byId(31), byId(29)],
|
||||
},
|
||||
{
|
||||
kind: 'indie-highlights',
|
||||
title: 'Indie Highlights',
|
||||
subtitle: 'Smaller teams shipping sharper ideas',
|
||||
items: [byId(30), byId(36), byId(34), byId(32), byId(28), byId(26)],
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaCatalog = catalog
|
||||
@@ -0,0 +1,118 @@
|
||||
import { continueWatchingItems } from '@/services/mock-data'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
isMockApiEnabled,
|
||||
mockResponse,
|
||||
type ServiceRequestOptions,
|
||||
} from '@/services/api-client'
|
||||
import type { ContinueWatchingItem, ProgressUpdateInput } from '@/types/domain'
|
||||
|
||||
const cloneContinueWatchingItem = (entry: ContinueWatchingItem): ContinueWatchingItem => ({
|
||||
item: {
|
||||
id: entry.item.id,
|
||||
provider: entry.item.provider,
|
||||
providerId: entry.item.providerId,
|
||||
title: entry.item.title,
|
||||
overview: entry.item.overview,
|
||||
type: entry.item.type,
|
||||
releaseDate: entry.item.releaseDate,
|
||||
genres: [...entry.item.genres],
|
||||
platforms: [...entry.item.platforms],
|
||||
rating: entry.item.rating,
|
||||
runtimeMinutes: entry.item.runtimeMinutes,
|
||||
artworkKey: entry.item.artworkKey,
|
||||
},
|
||||
progress: {
|
||||
itemId: entry.progress.itemId,
|
||||
seasonNumber: entry.progress.seasonNumber,
|
||||
episodeNumber: entry.progress.episodeNumber,
|
||||
progressPercent: entry.progress.progressPercent,
|
||||
lastWatchedAt: entry.progress.lastWatchedAt,
|
||||
},
|
||||
})
|
||||
|
||||
let mockContinueWatching = continueWatchingItems.map(cloneContinueWatchingItem)
|
||||
|
||||
const authHeaders = (accessToken: string): Record<string, string> => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
})
|
||||
|
||||
export const progressService = {
|
||||
getContinueWatching: async (
|
||||
accessToken: string,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<ContinueWatchingItem[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/progress/continue-watching',
|
||||
() => mockContinueWatching.map(cloneContinueWatchingItem),
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
return apiGet<ContinueWatchingItem[]>(
|
||||
'/api/v1/progress/continue-watching',
|
||||
{},
|
||||
authHeaders(accessToken),
|
||||
)
|
||||
},
|
||||
|
||||
updateProgress: async (
|
||||
accessToken: string,
|
||||
input: ProgressUpdateInput,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<ContinueWatchingItem[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/progress',
|
||||
() => {
|
||||
const next = mockContinueWatching.filter(
|
||||
(entry) =>
|
||||
!(
|
||||
entry.item.id === input.mediaId &&
|
||||
entry.progress.seasonNumber === input.seasonNumber &&
|
||||
entry.progress.episodeNumber === input.episodeNumber
|
||||
),
|
||||
)
|
||||
|
||||
if (input.progressPercent > 0 && input.progressPercent < 100) {
|
||||
const current = continueWatchingItems
|
||||
.map(cloneContinueWatchingItem)
|
||||
.find((entry) => entry.item.id === input.mediaId)
|
||||
|
||||
if (!current) {
|
||||
throw new Error('media not found')
|
||||
}
|
||||
|
||||
next.unshift({
|
||||
item: current.item,
|
||||
progress: {
|
||||
itemId: input.mediaId,
|
||||
seasonNumber: Math.max(1, input.seasonNumber),
|
||||
episodeNumber: Math.max(1, input.episodeNumber),
|
||||
progressPercent: input.progressPercent,
|
||||
lastWatchedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
mockContinueWatching = next.slice(0, 12)
|
||||
return mockContinueWatching.map(cloneContinueWatchingItem)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
return apiPost<ContinueWatchingItem[]>(
|
||||
'/api/v1/progress',
|
||||
{
|
||||
mediaId: input.mediaId,
|
||||
seasonNumber: input.seasonNumber,
|
||||
episodeNumber: input.episodeNumber,
|
||||
progressPercent: input.progressPercent,
|
||||
},
|
||||
authHeaders(accessToken),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mediaCatalog } from '@/services/mock-data'
|
||||
import { apiGet, isMockApiEnabled, mockResponse, type ServiceRequestOptions } from '@/services/api-client'
|
||||
import type { SearchFilters, SearchResult } from '@/types/domain'
|
||||
|
||||
const normalize = (value: string): string => value.trim().toLowerCase()
|
||||
|
||||
const calculateScore = (title: string, query: string, rating: number): number => {
|
||||
const cleanTitle = normalize(title)
|
||||
const cleanQuery = normalize(query)
|
||||
|
||||
if (cleanTitle === cleanQuery) {
|
||||
return Math.min(100, 70 + rating * 3)
|
||||
}
|
||||
|
||||
if (cleanTitle.startsWith(cleanQuery)) {
|
||||
return Math.min(98, 60 + rating * 3)
|
||||
}
|
||||
|
||||
return Math.min(95, 45 + rating * 4)
|
||||
}
|
||||
|
||||
export const searchService = {
|
||||
search: async (
|
||||
query: string,
|
||||
filters: SearchFilters,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<SearchResult[]> => {
|
||||
const term = normalize(query)
|
||||
|
||||
if (!term) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/search',
|
||||
() =>
|
||||
mediaCatalog
|
||||
.filter((item) => {
|
||||
if (filters.mediaType && filters.mediaType !== 'all' && item.type !== filters.mediaType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.genre && !item.genres.includes(filters.genre)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
normalize(item.title).includes(term) ||
|
||||
normalize(item.overview).includes(term) ||
|
||||
item.genres.some((genre) => normalize(genre).includes(term))
|
||||
)
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
mediaType: item.type,
|
||||
title: item.title,
|
||||
subtitle:
|
||||
item.type === 'game' && item.platforms.length > 0
|
||||
? `${item.genres.join(' • ')} · ${item.platforms.join(' • ')} · ${new Date(item.releaseDate).getFullYear()}`
|
||||
: `${item.genres.join(' • ')} · ${new Date(item.releaseDate).getFullYear()}`,
|
||||
genres: item.genres,
|
||||
score: calculateScore(item.title, term, item.rating),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 12),
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
return apiGet<SearchResult[]>('/api/v1/search', {
|
||||
query: term,
|
||||
genre: filters.genre,
|
||||
mediaType: filters.mediaType,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { dashboardService } from '@/services/dashboard-service'
|
||||
import { discoverService } from '@/services/discover-service'
|
||||
import { downloadService } from '@/services/download-service'
|
||||
import { gamesService } from '@/services/games-service'
|
||||
import { progressService } from '@/services/progress-service'
|
||||
import { searchService } from '@/services/search-service'
|
||||
import { watchLaterService } from '@/services/watch-later-service'
|
||||
|
||||
describe('mock services', () => {
|
||||
it('returns dashboard payload', async () => {
|
||||
const payload = await dashboardService.getDashboard({ latencyMs: 0 })
|
||||
|
||||
expect(payload.watchLater.length).toBeGreaterThan(0)
|
||||
expect(payload.gameBacklog.every((item) => item.type === 'game')).toBe(true)
|
||||
expect(payload.recommendations.length).toBeGreaterThan(0)
|
||||
expect(payload.activeDownloads.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns continue watching entries', async () => {
|
||||
const entries = await progressService.getContinueWatching('mock-access-token', { latencyMs: 0 })
|
||||
|
||||
expect(entries.length).toBeGreaterThan(0)
|
||||
expect(entries[0]?.progress.progressPercent).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('updates continue watching progress', async () => {
|
||||
const token = 'mock-access-token'
|
||||
const updated = await progressService.updateProgress(
|
||||
token,
|
||||
{
|
||||
mediaId: 2,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 7,
|
||||
progressPercent: 100,
|
||||
},
|
||||
{ latencyMs: 0 },
|
||||
)
|
||||
|
||||
expect(updated.some((entry) => entry.item.id === 2 && entry.progress.progressPercent === 100)).toBe(false)
|
||||
})
|
||||
|
||||
it('filters and paginates discover sections', async () => {
|
||||
const pageOne = await discoverService.getSections(
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
mediaType: 'game',
|
||||
genre: 'Action',
|
||||
},
|
||||
{ latencyMs: 0 },
|
||||
)
|
||||
|
||||
expect(pageOne.length).toBeGreaterThan(0)
|
||||
expect(pageOne.every((section) => section.items.length <= 2)).toBe(true)
|
||||
expect(pageOne.flatMap((section) => section.items).every((item) => item.type === 'game')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns dedicated game sections', async () => {
|
||||
const sections = await gamesService.getSections({ page: 1, pageSize: 3 }, { latencyMs: 0 })
|
||||
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
expect(sections.flatMap((section) => section.items).every((item) => item.type === 'game')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns ranked search results', async () => {
|
||||
const results = await searchService.search('zero', { mediaType: 'all' }, { latencyMs: 0 })
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0]?.title.toLowerCase()).toContain('zero')
|
||||
})
|
||||
|
||||
it('supports watch later add/remove flow', async () => {
|
||||
const token = 'mock-access-token'
|
||||
const before = await watchLaterService.getWatchLater(token, { latencyMs: 0 })
|
||||
const candidateID = 31
|
||||
|
||||
const afterAdd = await watchLaterService.addWatchLater(token, candidateID, { latencyMs: 0 })
|
||||
expect(afterAdd.some((item) => item.id === candidateID)).toBe(true)
|
||||
expect(afterAdd.find((item) => item.id === candidateID)?.type).toBe('game')
|
||||
|
||||
const afterRemove = await watchLaterService.removeWatchLater(token, candidateID, { latencyMs: 0 })
|
||||
expect(afterRemove.some((item) => item.id === candidateID)).toBe(false)
|
||||
expect(afterRemove.length).toBeLessThanOrEqual(afterAdd.length)
|
||||
expect(before.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('supports download create/list/cancel flow', async () => {
|
||||
const token = 'mock-access-token'
|
||||
const before = await downloadService.list(token, { limit: 30 }, { latencyMs: 0 })
|
||||
|
||||
const created = await downloadService.create(
|
||||
token,
|
||||
{
|
||||
sourceType: 'http',
|
||||
source: 'https://example.com/archive.mkv',
|
||||
title: 'Archive Pull',
|
||||
},
|
||||
{ latencyMs: 0 },
|
||||
)
|
||||
|
||||
expect(created.status).toBe('queued')
|
||||
|
||||
const afterCreate = await downloadService.list(token, { limit: 30 }, { latencyMs: 0 })
|
||||
expect(afterCreate.some((job) => job.id === created.id)).toBe(true)
|
||||
|
||||
const cancelled = await downloadService.cancel(token, created.id, { latencyMs: 0 })
|
||||
expect(cancelled.status).toBe('failed')
|
||||
expect(before.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('supports explicit error simulation', async () => {
|
||||
await expect(dashboardService.getDashboard({ latencyMs: 0, simulateError: true })).rejects.toThrow(
|
||||
'Mock request failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { dashboardPayload, mediaCatalog } from '@/services/mock-data'
|
||||
import {
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPost,
|
||||
isMockApiEnabled,
|
||||
mockResponse,
|
||||
type ServiceRequestOptions,
|
||||
} from '@/services/api-client'
|
||||
import type { MediaItem } from '@/types/domain'
|
||||
|
||||
const cloneMediaItem = (item: MediaItem): MediaItem => ({
|
||||
id: item.id,
|
||||
provider: item.provider,
|
||||
providerId: item.providerId,
|
||||
title: item.title,
|
||||
overview: item.overview,
|
||||
type: item.type,
|
||||
releaseDate: item.releaseDate,
|
||||
genres: [...item.genres],
|
||||
platforms: [...item.platforms],
|
||||
rating: item.rating,
|
||||
runtimeMinutes: item.runtimeMinutes,
|
||||
artworkKey: item.artworkKey,
|
||||
})
|
||||
|
||||
let mockWatchLaterItems: MediaItem[] = dashboardPayload.watchLater.map(cloneMediaItem)
|
||||
|
||||
const authHeaders = (accessToken: string): Record<string, string> => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
})
|
||||
|
||||
export const watchLaterService = {
|
||||
getWatchLater: async (
|
||||
accessToken: string,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<MediaItem[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse('/api/v1/watch-later', () => mockWatchLaterItems.map(cloneMediaItem), options)
|
||||
}
|
||||
|
||||
return apiGet<MediaItem[]>('/api/v1/watch-later', {}, authHeaders(accessToken))
|
||||
},
|
||||
|
||||
addWatchLater: async (
|
||||
accessToken: string,
|
||||
mediaId: number,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<MediaItem[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
'/api/v1/watch-later:add',
|
||||
() => {
|
||||
const existing = mockWatchLaterItems.find((item) => item.id === mediaId)
|
||||
if (existing) {
|
||||
return mockWatchLaterItems.map(cloneMediaItem)
|
||||
}
|
||||
|
||||
const media = mediaCatalog.find((item) => item.id === mediaId)
|
||||
if (!media) {
|
||||
throw new Error('media not found')
|
||||
}
|
||||
|
||||
mockWatchLaterItems = [cloneMediaItem(media), ...mockWatchLaterItems]
|
||||
return mockWatchLaterItems.map(cloneMediaItem)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
return apiPost<MediaItem[]>('/api/v1/watch-later', { mediaId }, authHeaders(accessToken))
|
||||
},
|
||||
|
||||
removeWatchLater: async (
|
||||
accessToken: string,
|
||||
mediaId: number,
|
||||
options: ServiceRequestOptions = {},
|
||||
): Promise<MediaItem[]> => {
|
||||
if (isMockApiEnabled()) {
|
||||
return mockResponse(
|
||||
`/api/v1/watch-later/${mediaId}:remove`,
|
||||
() => {
|
||||
mockWatchLaterItems = mockWatchLaterItems.filter((item) => item.id !== mediaId)
|
||||
return mockWatchLaterItems.map(cloneMediaItem)
|
||||
},
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
return apiDelete<MediaItem[]>(`/api/v1/watch-later/${mediaId}`, authHeaders(accessToken))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onMount,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentComponent,
|
||||
} from 'solid-js'
|
||||
import { authService } from '@/services/auth-service'
|
||||
import type { AuthResult, AuthUser } from '@/types/domain'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'seen-access-token'
|
||||
const REFRESH_TOKEN_KEY = 'seen-refresh-token'
|
||||
const USER_KEY = 'seen-auth-user'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: Accessor<AuthUser | null>
|
||||
accessToken: Accessor<string | null>
|
||||
refreshToken: Accessor<string | null>
|
||||
isAuthenticated: Accessor<boolean>
|
||||
isInitializing: Accessor<boolean>
|
||||
isBusy: Accessor<boolean>
|
||||
login: (input: { email: string; password: string }) => Promise<void>
|
||||
register: (input: { email: string; password: string; displayName?: string }) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>()
|
||||
|
||||
const readStoredUser = (): AuthUser | null => {
|
||||
const raw = window.localStorage.getItem(USER_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const persistSession = (payload: AuthResult): void => {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken)
|
||||
window.localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
|
||||
window.localStorage.setItem(USER_KEY, JSON.stringify(payload.user))
|
||||
}
|
||||
|
||||
const clearSessionStorage = (): void => {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
window.localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
window.localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent = (props) => {
|
||||
const [user, setUser] = createSignal<AuthUser | null>(null)
|
||||
const [accessToken, setAccessToken] = createSignal<string | null>(null)
|
||||
const [refreshToken, setRefreshToken] = createSignal<string | null>(null)
|
||||
const [isInitializing, setIsInitializing] = createSignal(true)
|
||||
const [isBusy, setIsBusy] = createSignal(false)
|
||||
|
||||
const isAuthenticated = createMemo(() => !!user() && !!accessToken())
|
||||
|
||||
const applySession = (payload: AuthResult): void => {
|
||||
setUser(payload.user)
|
||||
setAccessToken(payload.accessToken)
|
||||
setRefreshToken(payload.refreshToken)
|
||||
persistSession(payload)
|
||||
}
|
||||
|
||||
const logout = (): void => {
|
||||
setUser(null)
|
||||
setAccessToken(null)
|
||||
setRefreshToken(null)
|
||||
clearSessionStorage()
|
||||
}
|
||||
|
||||
const restore = async (): Promise<void> => {
|
||||
const storedAccessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
const storedRefreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
const storedUser = readStoredUser()
|
||||
|
||||
if (!storedAccessToken || !storedRefreshToken || !storedUser) {
|
||||
logout()
|
||||
return
|
||||
}
|
||||
|
||||
setAccessToken(storedAccessToken)
|
||||
setRefreshToken(storedRefreshToken)
|
||||
setUser(storedUser)
|
||||
|
||||
try {
|
||||
const resolvedUser = await authService.me(storedAccessToken)
|
||||
setUser(resolvedUser)
|
||||
window.localStorage.setItem(USER_KEY, JSON.stringify(resolvedUser))
|
||||
} catch {
|
||||
try {
|
||||
const refreshed = await authService.refresh({ refreshToken: storedRefreshToken })
|
||||
applySession(refreshed)
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await restore()
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
})
|
||||
|
||||
const login = async (input: { email: string; password: string }): Promise<void> => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const payload = await authService.login(input)
|
||||
applySession(payload)
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (input: {
|
||||
email: string
|
||||
password: string
|
||||
displayName?: string
|
||||
}): Promise<void> => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const payload = await authService.register(input)
|
||||
applySession(payload)
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
isInitializing,
|
||||
isBusy,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, waitFor } from '@solidjs/testing-library'
|
||||
import { ThemeProvider, THEME_STORAGE_KEY, loadStoredTheme, resolveTheme, useTheme } from '@/stores/theme-store'
|
||||
|
||||
const ThemeHarness = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => theme.setMode('light')}>
|
||||
Set Light
|
||||
</button>
|
||||
<span data-testid="resolved">{theme.resolved()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('theme-store', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
})
|
||||
|
||||
it('resolves system mode based on preference', () => {
|
||||
expect(resolveTheme('system', true)).toBe('dark')
|
||||
expect(resolveTheme('system', false)).toBe('light')
|
||||
})
|
||||
|
||||
it('loads persisted mode and falls back to system', () => {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||
expect(loadStoredTheme(localStorage)).toBe('dark')
|
||||
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'invalid')
|
||||
expect(loadStoredTheme(localStorage)).toBe('system')
|
||||
})
|
||||
|
||||
it('persists explicit mode and updates root dataset', async () => {
|
||||
const matchMediaMock = vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('matchMedia', matchMediaMock)
|
||||
|
||||
const { getByText, getByTestId } = render(() => (
|
||||
<ThemeProvider>
|
||||
<ThemeHarness />
|
||||
</ThemeProvider>
|
||||
))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
|
||||
expect(getByTestId('resolved')).toHaveTextContent('dark')
|
||||
})
|
||||
|
||||
getByText('Set Light').click()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light')
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
|
||||
expect(getByTestId('resolved')).toHaveTextContent('light')
|
||||
})
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentComponent,
|
||||
} from 'solid-js'
|
||||
import type { ThemeMode } from '@/types/domain'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'seen-theme-mode'
|
||||
|
||||
export type ResolvedTheme = 'dark' | 'light'
|
||||
|
||||
const isThemeMode = (value: string | null): value is ThemeMode =>
|
||||
value === 'dark' || value === 'light' || value === 'system'
|
||||
|
||||
export const resolveTheme = (mode: ThemeMode, prefersDark: boolean): ResolvedTheme => {
|
||||
if (mode === 'system') {
|
||||
return prefersDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export const loadStoredTheme = (storage: Storage | null): ThemeMode => {
|
||||
if (!storage) {
|
||||
return 'system'
|
||||
}
|
||||
|
||||
const storedValue = storage.getItem(THEME_STORAGE_KEY)
|
||||
return isThemeMode(storedValue) ? storedValue : 'system'
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: Accessor<ThemeMode>
|
||||
resolved: Accessor<ResolvedTheme>
|
||||
setMode: (next: ThemeMode) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
export const ThemeProvider: ParentComponent = (props) => {
|
||||
const storage = typeof window !== 'undefined' ? window.localStorage : null
|
||||
const [mode, setMode] = createSignal<ThemeMode>(loadStoredTheme(storage))
|
||||
const [prefersDark, setPrefersDark] = createSignal(false)
|
||||
|
||||
const resolved = createMemo<ResolvedTheme>(() => resolveTheme(mode(), prefersDark()))
|
||||
|
||||
onMount(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
setPrefersDark(media.matches)
|
||||
|
||||
const listener = (event: MediaQueryListEvent): void => {
|
||||
setPrefersDark(event.matches)
|
||||
}
|
||||
|
||||
media.addEventListener('change', listener)
|
||||
onCleanup(() => media.removeEventListener('change', listener))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const themeMode = mode()
|
||||
storage?.setItem(THEME_STORAGE_KEY, themeMode)
|
||||
document.documentElement.setAttribute('data-theme', resolved())
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
resolved,
|
||||
setMode,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cleanup } from '@solidjs/testing-library'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
export type MediaType = 'movie' | 'show' | 'game'
|
||||
|
||||
export type MediaProvider = 'tmdb' | 'igdb'
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
export type UserRole = 'user' | 'admin'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string
|
||||
role: UserRole
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: number
|
||||
provider: MediaProvider
|
||||
providerId: number
|
||||
title: string
|
||||
overview: string
|
||||
type: MediaType
|
||||
releaseDate: string
|
||||
genres: string[]
|
||||
platforms: string[]
|
||||
rating: number
|
||||
runtimeMinutes: number
|
||||
artworkKey: string
|
||||
}
|
||||
|
||||
export interface TVShowItem extends MediaItem {
|
||||
type: 'show'
|
||||
seasonCount: number
|
||||
episodeCount: number
|
||||
}
|
||||
|
||||
export interface EpisodeProgress {
|
||||
itemId: number
|
||||
seasonNumber: number
|
||||
episodeNumber: number
|
||||
progressPercent: number
|
||||
lastWatchedAt: string
|
||||
}
|
||||
|
||||
export interface ProgressUpdateInput {
|
||||
mediaId: number
|
||||
seasonNumber: number
|
||||
episodeNumber: number
|
||||
progressPercent: number
|
||||
}
|
||||
|
||||
export type DownloadStatus = 'queued' | 'downloading' | 'stalled' | 'completed' | 'failed'
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string
|
||||
title: string
|
||||
status: DownloadStatus
|
||||
progressPercent: number
|
||||
downloadSpeedMbps: number
|
||||
etaMinutes: number
|
||||
sourceType: 'magnet' | 'torrent' | 'direct' | 'http'
|
||||
}
|
||||
|
||||
export interface RecommendationItem {
|
||||
id: number
|
||||
reason: string
|
||||
score: number
|
||||
media: MediaItem
|
||||
}
|
||||
|
||||
export interface ContinueWatchingItem {
|
||||
item: MediaItem
|
||||
progress: EpisodeProgress
|
||||
}
|
||||
|
||||
export interface DashboardPayload {
|
||||
watchLater: MediaItem[]
|
||||
gameBacklog: MediaItem[]
|
||||
activeDownloads: DownloadJob[]
|
||||
recommendations: RecommendationItem[]
|
||||
trending: MediaItem[]
|
||||
upcoming: MediaItem[]
|
||||
recentlyWatched: MediaItem[]
|
||||
}
|
||||
|
||||
export type DiscoverSectionKind =
|
||||
| 'trending'
|
||||
| 'popular'
|
||||
| 'top-rated'
|
||||
| 'upcoming'
|
||||
| 'now-playing'
|
||||
| 'airing-today'
|
||||
| 'recently-released-games'
|
||||
| 'most-anticipated-games'
|
||||
| 'indie-highlights'
|
||||
| 'genre'
|
||||
|
||||
export interface DiscoverSection {
|
||||
kind: DiscoverSectionKind
|
||||
title: string
|
||||
subtitle: string
|
||||
items: MediaItem[]
|
||||
}
|
||||
|
||||
export interface DiscoverParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
query?: string
|
||||
genre?: string
|
||||
mediaType?: MediaType | 'all'
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
genre?: string
|
||||
mediaType?: MediaType | 'all'
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: number
|
||||
mediaType: MediaType
|
||||
title: string
|
||||
subtitle: string
|
||||
genres: string[]
|
||||
score: number
|
||||
}
|
||||
|
||||
export type ApiStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
export interface ApiState<T> {
|
||||
status: ApiStatus
|
||||
data: T | null
|
||||
error: string | null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type ArtworkTone = 'neutral' | 'accent' | 'secondary'
|
||||
|
||||
const hashSeed = (seed: string): number => {
|
||||
let hash = 0
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash << 5) - hash + seed.charCodeAt(index)
|
||||
hash |= 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
const artworkTones: ArtworkTone[] = ['neutral', 'accent', 'secondary']
|
||||
|
||||
export const artworkTone = (seed: string): ArtworkTone =>
|
||||
artworkTones[hashSeed(seed) % artworkTones.length] ?? 'neutral'
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
|
||||
export const cn = (...inputs: ClassValue[]): string => clsx(inputs)
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MediaType } from '@/types/domain'
|
||||
|
||||
export const formatDate = (isoDate: string): string =>
|
||||
new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(
|
||||
new Date(isoDate),
|
||||
)
|
||||
|
||||
export const formatRating = (rating: number): string => rating.toFixed(1)
|
||||
|
||||
export const formatRuntime = (minutes: number, mediaType?: MediaType): string => {
|
||||
if (minutes <= 0) {
|
||||
return mediaType === 'game' ? 'TBD' : '0m'
|
||||
}
|
||||
|
||||
if (mediaType === 'game') {
|
||||
const hours = minutes / 60
|
||||
return hours >= 10 ? `Est. ${Math.round(hours)}h` : `Est. ${hours.toFixed(1)}h`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = minutes % 60
|
||||
return hours === 0 ? `${remainder}m` : `${hours}h ${remainder}m`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MediaItem, MediaType } from '@/types/domain'
|
||||
import { formatRuntime } from '@/utils/format'
|
||||
|
||||
export const mediaTypeLabel = (type: MediaType): string =>
|
||||
type === 'movie' ? 'Movie' : type === 'show' ? 'Show' : 'Game'
|
||||
|
||||
export const mediaBadgeVariant = (type: MediaType): 'accent' | 'secondary' | 'neutral' =>
|
||||
type === 'movie' ? 'accent' : type === 'show' ? 'secondary' : 'neutral'
|
||||
|
||||
export const mediaMeta = (item: MediaItem): string =>
|
||||
item.type === 'game' && item.platforms.length > 0
|
||||
? item.platforms.slice(0, 2).join(' • ')
|
||||
: formatRuntime(item.runtimeMinutes, item.type)
|
||||
|
||||
export const mediaYear = (isoDate: string): string => String(new Date(isoDate).getFullYear())
|
||||
|
||||
export const mediaMonogram = (title: string): string =>
|
||||
title
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
.slice(0, 2) || 'SN'
|
||||
@@ -0,0 +1,130 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
|
||||
display: ['"Space Grotesk"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
// Surface Architecture
|
||||
'surface-dim': 'rgb(var(--color-surface-dim) / <alpha-value>)',
|
||||
'surface': 'rgb(var(--color-surface) / <alpha-value>)',
|
||||
'surface-bright': 'rgb(var(--color-surface-bright) / <alpha-value>)',
|
||||
'surface-lowest': 'rgb(var(--color-surface-container-lowest) / <alpha-value>)',
|
||||
'surface-low': 'rgb(var(--color-surface-container-low) / <alpha-value>)',
|
||||
'surface-container': 'rgb(var(--color-surface-container) / <alpha-value>)',
|
||||
'surface-high': 'rgb(var(--color-surface-container-high) / <alpha-value>)',
|
||||
'surface-highest': 'rgb(var(--color-surface-container-highest) / <alpha-value>)',
|
||||
// Legacy mappings
|
||||
bg: 'rgb(var(--color-bg) / <alpha-value>)',
|
||||
fg: 'rgb(var(--color-fg) / <alpha-value>)',
|
||||
muted: 'rgb(var(--color-muted) / <alpha-value>)',
|
||||
'muted-fg': 'rgb(var(--color-muted-fg) / <alpha-value>)',
|
||||
card: 'rgb(var(--color-card) / <alpha-value>)',
|
||||
border: 'rgb(var(--color-border) / <alpha-value>)',
|
||||
// Primary
|
||||
primary: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||
'primary-container': 'rgb(var(--color-primary-container) / <alpha-value>)',
|
||||
'primary-fixed': 'rgb(var(--color-primary-fixed) / <alpha-value>)',
|
||||
'primary-fixed-dim': 'rgb(var(--color-primary-fixed-dim) / <alpha-value>)',
|
||||
'on-primary': 'rgb(var(--color-on-primary) / <alpha-value>)',
|
||||
'on-primary-container': 'rgb(var(--color-on-primary-container) / <alpha-value>)',
|
||||
// Accent alias
|
||||
accent: 'rgb(var(--color-accent) / <alpha-value>)',
|
||||
// Secondary
|
||||
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
|
||||
'secondary-container': 'rgb(var(--color-secondary-container) / <alpha-value>)',
|
||||
'secondary-fixed': 'rgb(var(--color-secondary-fixed) / <alpha-value>)',
|
||||
'secondary-fixed-dim': 'rgb(var(--color-secondary-fixed-dim) / <alpha-value>)',
|
||||
'on-secondary': 'rgb(var(--color-on-secondary) / <alpha-value>)',
|
||||
'on-secondary-container': 'rgb(var(--color-on-secondary-container) / <alpha-value>)',
|
||||
// Tertiary
|
||||
tertiary: 'rgb(var(--color-tertiary) / <alpha-value>)',
|
||||
'tertiary-container': 'rgb(var(--color-tertiary-container) / <alpha-value>)',
|
||||
// Semantic
|
||||
success: 'rgb(var(--color-success) / <alpha-value>)',
|
||||
warning: 'rgb(var(--color-warning) / <alpha-value>)',
|
||||
danger: 'rgb(var(--color-danger) / <alpha-value>)',
|
||||
'on-danger': 'rgb(var(--color-on-danger) / <alpha-value>)',
|
||||
// Outlines
|
||||
outline: 'rgb(var(--color-outline) / <alpha-value>)',
|
||||
'outline-variant': 'rgb(var(--color-outline-variant) / <alpha-value>)',
|
||||
'on-surface-variant': 'rgb(var(--color-on-surface-variant) / <alpha-value>)',
|
||||
// Inverse
|
||||
'inverse-surface': 'rgb(var(--color-inverse-surface) / <alpha-value>)',
|
||||
'inverse-on-surface': 'rgb(var(--color-inverse-on-surface) / <alpha-value>)',
|
||||
'inverse-primary': 'rgb(var(--color-inverse-primary) / <alpha-value>)',
|
||||
// Tint
|
||||
'surface-tint': 'rgb(var(--color-surface-tint) / <alpha-value>)',
|
||||
},
|
||||
boxShadow: {
|
||||
brutal: '8px 8px 0 rgb(var(--color-primary))',
|
||||
'brutal-sm': '4px 4px 0 rgb(var(--color-primary))',
|
||||
'brutal-lg': '12px 12px 0 rgb(var(--color-primary))',
|
||||
'brutal-hover': '12px 12px 0 rgb(var(--color-primary))',
|
||||
'brutal-outline': '8px 8px 0 rgb(var(--color-outline))',
|
||||
none: 'none',
|
||||
},
|
||||
borderRadius: {
|
||||
none: '0',
|
||||
DEFAULT: '0',
|
||||
},
|
||||
borderWidth: {
|
||||
DEFAULT: '3px',
|
||||
0: '0',
|
||||
2: '2px',
|
||||
3: '3px',
|
||||
4: '4px',
|
||||
6: '6px',
|
||||
8: '8px',
|
||||
},
|
||||
keyframes: {
|
||||
'brutal-appear': {
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
'brutal-scale': {
|
||||
'0%': { transform: 'scale(0.9)' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
'100%': { transform: 'scale(1)' },
|
||||
},
|
||||
'glitch-anim': {
|
||||
'0%': { clip: 'rect(42px, 9999px, 44px, 0)' },
|
||||
'5%': { clip: 'rect(12px, 9999px, 59px, 0)' },
|
||||
'10%': { clip: 'rect(48px, 9999px, 29px, 0)' },
|
||||
'15%': { clip: 'rect(42px, 9999px, 73px, 0)' },
|
||||
'20%': { clip: 'rect(63px, 9999px, 27px, 0)' },
|
||||
'100%': { clip: 'rect(4px, 9999px, 91px, 0)' },
|
||||
},
|
||||
'flicker': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'42%': { opacity: 0 },
|
||||
'43%': { opacity: 1 },
|
||||
'48%': { opacity: 0 },
|
||||
'49%': { opacity: 1 },
|
||||
},
|
||||
'slide-brutal': {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'brutal-appear': 'brutal-appear 0.1s steps(1) both',
|
||||
'brutal-scale': 'brutal-scale 0.1s steps(2) both',
|
||||
'glitch': 'glitch-anim 3s infinite linear alternate-reverse',
|
||||
'flicker': 'flicker 5s infinite',
|
||||
'slide-brutal': 'slide-brutal 0.2s steps(3) both',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-primary': 'linear-gradient(135deg, rgb(var(--color-primary)) 0%, rgb(var(--color-primary)) 100%)',
|
||||
'gradient-secondary': 'linear-gradient(135deg, rgb(var(--color-secondary)) 0%, rgb(var(--color-secondary)) 100%)',
|
||||
'gradient-mesh': 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgb(var(--color-primary) / 0.03) 10px, rgb(var(--color-primary) / 0.03) 20px)',
|
||||
'brutal-stripes': 'repeating-linear-gradient(45deg, transparent, transparent 20px, rgb(var(--color-primary) / 0.05) 20px, rgb(var(--color-primary) / 0.05) 40px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const routes = [
|
||||
'/app/dashboard',
|
||||
'/app/discover',
|
||||
'/app/games',
|
||||
'/app/movies',
|
||||
'/app/shows',
|
||||
'/app/watch-later',
|
||||
'/app/watched',
|
||||
'/app/downloads',
|
||||
'/app/calendar',
|
||||
'/app/recommendations',
|
||||
'/app/library',
|
||||
'/app/collections',
|
||||
'/app/settings',
|
||||
'/app/admin',
|
||||
]
|
||||
|
||||
const routeTestIds = new Map<string, string>([
|
||||
['/app/dashboard', 'dashboard-page'],
|
||||
['/app/discover', 'discover-page'],
|
||||
['/app/games', 'games-page'],
|
||||
['/app/movies', 'movies-page'],
|
||||
['/app/shows', 'shows-page'],
|
||||
['/app/watch-later', 'watch-later-page'],
|
||||
['/app/watched', 'watched-page'],
|
||||
['/app/downloads', 'downloads-page'],
|
||||
['/app/calendar', 'calendar-page'],
|
||||
['/app/recommendations', 'recommendations-page'],
|
||||
['/app/library', 'library-page'],
|
||||
['/app/collections', 'collections-page'],
|
||||
['/app/settings', 'settings-page'],
|
||||
['/app/admin', 'admin-page'],
|
||||
])
|
||||
|
||||
const authenticate = async (page: import('@playwright/test').Page): Promise<void> => {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.getByLabel('Email').fill('demo@seen.local')
|
||||
await page.getByLabel('Password').fill('password123')
|
||||
await page.getByRole('button', { name: /Sign In to Dashboard/i }).click()
|
||||
await expect(page).toHaveURL(/\/app\/dashboard$/)
|
||||
}
|
||||
|
||||
test('loads dashboard with widgets', async ({ page }) => {
|
||||
await authenticate(page)
|
||||
|
||||
await expect(page.getByTestId('dashboard-page')).toBeVisible()
|
||||
await expect(page.getByText('WELCOME', { exact: false })).toBeVisible()
|
||||
await expect(page.getByText('CONTINUE', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('RECOMMENDED', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('theme mode persists after refresh', async ({ page }) => {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.getByRole('button', { name: 'Light', exact: true }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
await expect.poll(async () => page.evaluate(() => localStorage.getItem('seen-theme-mode'))).toBe('light')
|
||||
|
||||
await page.getByLabel('Email').fill('demo@seen.local')
|
||||
await page.getByLabel('Password').fill('password123')
|
||||
await page.getByRole('button', { name: /Sign In to Dashboard/i }).click()
|
||||
await expect(page).toHaveURL(/\/app\/dashboard$/)
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page).toHaveURL(/\/app\/dashboard$/)
|
||||
await expect.poll(async () => page.evaluate(() => localStorage.getItem('seen-theme-mode'))).toBe('light')
|
||||
})
|
||||
|
||||
test('sidebar routes navigate without crashes', async ({ page }) => {
|
||||
await authenticate(page)
|
||||
|
||||
await expect(page.getByTestId('dashboard-page')).toBeVisible()
|
||||
|
||||
for (const route of routes.slice(1)) {
|
||||
const testId = routeTestIds.get(route)!
|
||||
|
||||
await page.evaluate((targetRoute) => {
|
||||
window.history.pushState({}, '', targetRoute)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, route)
|
||||
await expect(page).toHaveURL(new RegExp(`${route}$`))
|
||||
await page.getByTestId(testId).waitFor({ state: 'visible', timeout: 15_000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('dashboard transitions from loading skeletons to content', async ({ page }) => {
|
||||
await authenticate(page)
|
||||
|
||||
await expect(page.getByTestId('skeleton').first()).toBeVisible()
|
||||
await expect(page.getByText('WELCOME', { exact: false })).toBeVisible()
|
||||
await expect(page.getByTestId('skeleton').first()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('discover search and filters update displayed results', async ({ page }) => {
|
||||
await authenticate(page)
|
||||
await page.locator('[data-route="/app/discover"]').first().click()
|
||||
await expect(page).toHaveURL(/\/app\/discover$/)
|
||||
|
||||
await page.getByLabel('Discover search').fill('zero')
|
||||
const searchResults = page.getByTestId('discover-search-results')
|
||||
|
||||
await expect(searchResults).toBeVisible()
|
||||
await expect(searchResults.getByText('Searching…')).toBeVisible()
|
||||
await expect(searchResults.getByText('Searching…')).not.toBeVisible()
|
||||
await expect(searchResults.getByText('Zero Meridian')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Clear', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Sci-Fi', exact: true }).click()
|
||||
await expect(page.getByText('Trending · Sci-Fi')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'All', exact: true }).click()
|
||||
|
||||
const loadMoreButton = page.getByRole('button', { name: 'Load More' })
|
||||
await expect(loadMoreButton).toBeEnabled()
|
||||
|
||||
const firstLoadCount = await page.locator('article').count()
|
||||
await loadMoreButton.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const nextCount = await page.locator('article').count()
|
||||
return nextCount
|
||||
})
|
||||
.toBeGreaterThan(firstLoadCount)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import solid from 'vite-plugin-solid'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8081',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user