Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 147 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A Next.js-based Plex management platform that combines server management tools,
- **Auth**: NextAuth.js (Plex PIN-based authentication)
- **State**: TanStack Query (React Query) for client-side data fetching
- **Styling**: Tailwind CSS with utility-first approach
- **UI Components**: shadcn/ui (Radix primitives + CVA variants)
- **Animation**: Framer Motion
- **Validation**: Zod schemas for all inputs/responses
- **Testing**: Jest + Testing Library + Playwright (E2E)
Expand Down Expand Up @@ -219,29 +220,163 @@ if (result.error) {
- **Shared UI** → Presentational components (e.g., `<Card>`, `<Badge>`)
- **Business logic** → Pure functions (easy to test)

## UI Component Guidelines
## UI Component Guidelines (shadcn/ui)

### Always Use Existing UI Components
### Always Use shadcn/ui Components

**⚠️ DO NOT create new dropdowns, selectors, checkboxes, buttons, etc.**
**⚠️ DO NOT create custom UI primitives**

- ✅ **CORRECT**: Import and use components from `components/ui/`
- ❌ **WRONG**: Creating custom `<select>`, raw `<input type="checkbox">`, or custom dropdown implementations

**Why**: Ensures consistent styling, behavior, and accessibility across the app

**Before creating new UI**: Always check if a component exists in `components/ui/` first

### Common UI Components Available
### Available Components

Located in `components/ui/`:
- Form inputs and controls
- Buttons and interactive elements
- Layout components
- Data display components
- Feedback components (toasts, alerts)

Check the directory for the complete list before implementing custom UI elements.
| Component | Import | Description |
|-----------|--------|-------------|
| `Button` | `@/components/ui/button` | Primary, secondary, ghost, danger, success, outline, link variants |
| `Input` | `@/components/ui/input` | Text input with error state support |
| `Textarea` | `@/components/ui/textarea` | Multi-line input with resize options |
| `Checkbox` | `@/components/ui/checkbox` | Radix checkbox, includes `CheckboxField` composite |
| `Select` | `@/components/ui/select` | Radix select with full keyboard navigation |
| `Dialog` | `@/components/ui/dialog` | Modal dialogs |
| `AlertDialog` | `@/components/ui/alert-dialog` | Confirmation modals (`ConfirmModal`) |
| `Card` | `@/components/ui/card` | Content containers |
| `Alert` | `@/components/ui/alert` | Inline alerts and notices |
| `Badge` | `@/components/ui/badge` | Status indicators |
| `Tabs` | `@/components/ui/tabs` | Tab navigation |
| `Tooltip` | `@/components/ui/tooltip` | Hover tooltips |
| `Table` | `@/components/ui/table` | Data tables |
| `Label` | `@/components/ui/label` | Form labels |

### Usage Examples

```typescript
// Button with variants
import { Button } from '@/components/ui/button'

<Button variant="primary">Save</Button>
<Button variant="danger">Delete</Button>
<Button variant="ghost" size="sm">Cancel</Button>

// Form inputs with error state
import { Input } from '@/components/ui/input'

<Input
name="email"
error={!!errors.email}
placeholder="Enter email"
/>

// Checkbox with label
import { CheckboxField } from '@/components/ui/checkbox'

<CheckboxField
label="Enable notifications"
description="Receive email updates"
checked={enabled}
onCheckedChange={setEnabled}
/>

// Select dropdown
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
<SelectItem value="b">Option B</SelectItem>
</SelectContent>
</Select>

// Toast notifications
import { useToast } from '@/components/ui/sonner'

const { showSuccess, showError } = useToast()
showSuccess('Changes saved')
showError('Failed to save')

// Confirmation modal
import { ConfirmModal } from '@/components/ui/alert-dialog'

<ConfirmModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onConfirm={handleDelete}
title="Delete Item"
message="Are you sure?"
confirmText="Delete"
confirmButtonClass="bg-red-600 hover:bg-red-700"
/>
```

### Component Variants (CVA)

Components use `class-variance-authority` for type-safe variants:

```typescript
// Button variants: primary | secondary | ghost | danger | success | outline | link
// Button sizes: sm | md | lg | icon

// Input/Textarea sizes: sm | md | lg
// Input/Textarea error: boolean (shows red border)

// Badge variants: default | secondary | success | warning | destructive | outline
```

### Theme (Dark Mode)

All components use CSS variables defined in `app/globals.css`:
- Background: slate-900
- Foreground: white
- Primary: cyan-500 (with purple gradient accents)
- Destructive: red-500
- Border: slate-600

### Adding New shadcn Components

1. Check [ui.shadcn.com](https://ui.shadcn.com) for available components
2. Copy the component code (don't use `npx shadcn` - customize manually)
3. Place in `components/ui/`
4. Customize colors to match theme (replace zinc/gray with slate, ring colors with cyan)
5. Add `"use client"` directive if component uses hooks/interactivity

### Auto-Generated Test IDs

Input and Textarea components auto-generate `data-testid` from the `name` prop if not explicitly provided:

```typescript
// With name="email", auto-generates data-testid="setup-input-email"
<Input name="email" />

// Explicit data-testid takes precedence
<Input name="email" data-testid="custom-id" />
```

This pattern maintains backward compatibility with existing E2E tests that use `setup-input-*` selectors.

### Toast Duration Conventions

The `useToast` hook applies different default durations by type:

| Toast Type | Default Duration | Reason |
|------------|-----------------|--------|
| `showError` | 5000ms (5s) | Errors need more read time |
| `showSuccess` | Sonner default | Quick acknowledgment |
| `showInfo` | Sonner default | Quick acknowledgment |

```typescript
const { showSuccess, showError, showInfo } = useToast()

showError('Failed to save') // Stays 5s by default
showSuccess('Saved!') // Sonner default (~4s)
showError('Custom', 10000) // Override to 10s
```

## Testing Strategy

Expand Down
7 changes: 4 additions & 3 deletions app/auth/callback/plex/callback-client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { checkServerAccess } from "@/actions/auth"
import { Button } from "@/components/ui/button"
import { getPlexAuthToken } from "@/lib/plex-auth"
import { getSession, signIn } from "next-auth/react"
import { useRouter, useSearchParams } from "next/navigation"
Expand Down Expand Up @@ -211,12 +212,12 @@ export function PlexCallbackPageClient() {
{errorTitle}
</h1>
<p className="text-center text-slate-300 mb-6">{error}</p>
<button
<Button
onClick={() => router.push("/")}
className="w-full py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors"
className="w-full"
>
{isInviteError ? "Go Home" : "Try Again"}
</button>
</Button>
</div>
</div>
)
Expand Down
21 changes: 13 additions & 8 deletions app/auth/denied/denied-client.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import { Button } from "@/components/ui/button"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
Expand Down Expand Up @@ -176,19 +177,23 @@ export function DeniedAccessPageClient() {
transition={{ delay: 0.8 }}
className="flex flex-col gap-3"
>
<Link
href="/"
className="w-full py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors text-center"
<Button
asChild
variant="danger"
className="w-full"
>
Try Again
</Link>
<button
<Link href="/">
Try Again
</Link>
</Button>
<Button
onClick={() => router.push("/")}
data-testid="return-home-button"
className="w-full py-3 px-4 border border-slate-600 rounded-md shadow-sm text-sm font-medium text-slate-300 bg-slate-700/50 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 transition-colors"
variant="secondary"
className="w-full"
>
Return Home
</button>
</Button>
</motion.div>
</motion.div>
</div>
Expand Down
117 changes: 109 additions & 8 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,113 @@
@import "tailwindcss";
@import "tw-animate-css";

/* shadcn/ui CSS Variables - Dark Theme */
:root {
/* Base colors - slate/dark theme */
--background: 222.2 47.4% 11.2%;
--foreground: 210 40% 98%;

/* Card/Surface colors */
--card: 222.2 47.4% 11.2%;
--card-foreground: 210 40% 98%;

/* Popover colors */
--popover: 222.2 47.4% 11.2%;
--popover-foreground: 210 40% 98%;

/* Primary - cyan accent */
--primary: 187 100% 42%;
--primary-foreground: 0 0% 100%;

/* Secondary - slate-800 */
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;

/* Muted - slate-800 */
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;

/* Accent - purple */
--accent: 271 91% 65%;
--accent-foreground: 0 0% 100%;

/* Destructive - red */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;

/* Success - green */
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;

/* Border and input */
--border: 217.2 32.6% 30%;
--input: 217.2 32.6% 30%;
--ring: 187 100% 42%;

/* Chart colors */
--chart-1: 187 100% 42%;
--chart-2: 271 91% 65%;
--chart-3: 142 71% 45%;
--chart-4: 38 92% 50%;
--chart-5: 0 84% 60%;

/* Sidebar colors */
--sidebar: 222.2 47.4% 11.2%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 100% 42%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 30%;
--sidebar-ring: 187 100% 42%;

/* Radius */
--radius: 0.5rem;
}

/* Tailwind v4 theme integration */
@theme inline {
/* Colors */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));

/* Radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);

/* Animations */
--animate-float: float 2s ease-in-out infinite;
--animate-wiggle: wiggle 1.5s ease-in-out infinite;
--animate-blink: blink 3s ease-in-out infinite;
--animate-twinkle: twinkle 3s ease-in-out infinite;
--animate-fade-in: fade-in 0.6s ease-out;
}

/* Base mobile-friendly styles */
html,
Expand All @@ -14,14 +123,6 @@ body {
}
}

@theme {
--animate-float: float 2s ease-in-out infinite;
--animate-wiggle: wiggle 1.5s ease-in-out infinite;
--animate-blink: blink 3s ease-in-out infinite;
--animate-twinkle: twinkle 3s ease-in-out infinite;
--animate-fade-in: fade-in 0.6s ease-out;
}

@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
Expand Down
7 changes: 4 additions & 3 deletions app/invite/[code]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { validateInvite } from "@/actions/invite"
import { getServerName } from "@/actions/server-info"
import { PlexSignInButton } from "@/components/auth/plex-sign-in-button"
import { JellyfinSignInForm } from "@/components/auth/jellyfin-sign-in-form"
import { Button } from "@/components/ui/button"
import { motion } from "framer-motion"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
Expand Down Expand Up @@ -115,12 +116,12 @@ export default function InvitePage() {
</motion.div>
<h1 data-testid="invalid-invite-heading" className="text-xl sm:text-2xl font-bold text-white mb-2">Invalid Invite</h1>
<p className="text-slate-300 mb-6 text-sm sm:text-base px-2">{error}</p>
<button
<Button
onClick={() => router.push("/")}
className="w-full py-2.5 sm:py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors"
className="w-full"
>
Go Home
</button>
</Button>
</div>
</motion.div>
</div>
Expand Down
Loading
Loading