A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: public profile

+5157 -735
+260
.agents/skills/shadcn/SKILL.md
··· 1 + --- 2 + name: shadcn 3 + description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset". 4 + user-invocable: false 5 + allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) 6 + --- 7 + 8 + # shadcn/ui 9 + 10 + A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI. 11 + 12 + > **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. 13 + 14 + ## Current Project Context 15 + 16 + ```json 17 + !`npx shadcn@latest info --json` 18 + ``` 19 + 20 + The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component. 21 + 22 + ## Principles 23 + 24 + 1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too. 25 + 2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table. 26 + 3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc. 27 + 4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`. 28 + 29 + ## Critical Rules 30 + 31 + These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs. 32 + 33 + ### Styling & Tailwind → [styling.md](./rules/styling.md) 34 + 35 + - **`className` for layout, not styling.** Never override component colors or typography. 36 + - **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`. 37 + - **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`. 38 + - **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`. 39 + - **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`). 40 + - **Use `cn()` for conditional classes.** Don't write manual template literal ternaries. 41 + - **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking. 42 + 43 + ### Forms & Inputs → [forms.md](./rules/forms.md) 44 + 45 + - **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout. 46 + - **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`. 47 + - **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.** 48 + - **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state. 49 + - **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading. 50 + - **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control. 51 + 52 + ### Component Structure → [composition.md](./rules/composition.md) 53 + 54 + - **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`. 55 + - **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md) 56 + - **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden. 57 + - **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`. 58 + - **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`. 59 + - **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`. 60 + - **`Avatar` always needs `AvatarFallback`.** For when the image fails to load. 61 + 62 + ### Use Components, Not Custom Markup → [composition.md](./rules/composition.md) 63 + 64 + - **Use existing components before custom markup.** Check if a component exists before writing a styled `div`. 65 + - **Callouts use `Alert`.** Don't build custom styled divs. 66 + - **Empty states use `Empty`.** Don't build custom empty state markup. 67 + - **Toast via `sonner`.** Use `toast()` from `sonner`. 68 + - **Use `Separator`** instead of `<hr>` or `<div className="border-t">`. 69 + - **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs. 70 + - **Use `Badge`** instead of custom styled spans. 71 + 72 + ### Icons → [icons.md](./rules/icons.md) 73 + 74 + - **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon. 75 + - **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`. 76 + - **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup. 77 + 78 + ### CLI 79 + 80 + - **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`. 81 + - **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing. 82 + 83 + ## Key Patterns 84 + 85 + These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above. 86 + 87 + ```tsx 88 + // Form layout: FieldGroup + Field, not div + Label. 89 + <FieldGroup> 90 + <Field> 91 + <FieldLabel htmlFor="email">Email</FieldLabel> 92 + <Input id="email" /> 93 + </Field> 94 + </FieldGroup> 95 + 96 + // Validation: data-invalid on Field, aria-invalid on the control. 97 + <Field data-invalid> 98 + <FieldLabel>Email</FieldLabel> 99 + <Input aria-invalid /> 100 + <FieldDescription>Invalid email.</FieldDescription> 101 + </Field> 102 + 103 + // Icons in buttons: data-icon, no sizing classes. 104 + <Button> 105 + <SearchIcon data-icon="inline-start" /> 106 + Search 107 + </Button> 108 + 109 + // Spacing: gap-*, not space-y-*. 110 + <div className="flex flex-col gap-4"> // correct 111 + <div className="space-y-4"> // wrong 112 + 113 + // Equal dimensions: size-*, not w-* h-*. 114 + <Avatar className="size-10"> // correct 115 + <Avatar className="w-10 h-10"> // wrong 116 + 117 + // Status colors: Badge variants or semantic tokens, not raw colors. 118 + <Badge variant="secondary">+20.1%</Badge> // correct 119 + <span className="text-emerald-600">+20.1%</span> // wrong 120 + ``` 121 + 122 + ## Component Selection 123 + 124 + | Need | Use | 125 + | -------------------------- | --------------------------------------------------------------------------------------------------- | 126 + | Button/action | `Button` with appropriate variant | 127 + | Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` | 128 + | Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` | 129 + | Data display | `Table`, `Card`, `Badge`, `Avatar` | 130 + | Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` | 131 + | Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) | 132 + | Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` | 133 + | Command palette | `Command` inside `Dialog` | 134 + | Charts | `Chart` (wraps Recharts) | 135 + | Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` | 136 + | Empty states | `Empty` | 137 + | Menus | `DropdownMenu`, `ContextMenu`, `Menubar` | 138 + | Tooltips/info | `Tooltip`, `HoverCard`, `Popover` | 139 + 140 + ## Key Fields 141 + 142 + The injected project context contains these key fields: 143 + 144 + - **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode. 145 + - **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive. 146 + - **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`. 147 + - **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one. 148 + - **`style`** → component visual treatment (e.g. `nova`, `vega`). 149 + - **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props. 150 + - **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`. 151 + - **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc. 152 + - **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA). 153 + - **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`). 154 + - **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information. 155 + 156 + See [cli.md — `info` command](./cli.md) for the full field reference. 157 + 158 + ## Component Docs, Examples, and Usage 159 + 160 + Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content. 161 + 162 + ```bash 163 + npx shadcn@latest docs button dialog select 164 + ``` 165 + 166 + **When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing. 167 + 168 + ## Workflow 169 + 170 + 1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh. 171 + 2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed. 172 + 3. **Find components** — `npx shadcn@latest search`. 173 + 4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`. 174 + 5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below). 175 + 6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project. 176 + 7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on. 177 + 8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user. 178 + 9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**? 179 + - **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values. 180 + - **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder. 181 + - **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables. 182 + - **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms. 183 + - **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually. 184 + - **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is. 185 + - **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base. 186 + 187 + ## Updating Components 188 + 189 + When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.** 190 + 191 + 1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected. 192 + 2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local. 193 + 3. Decide per file based on the diff: 194 + - No local changes → safe to overwrite. 195 + - Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications. 196 + - User says "just update everything" → use `--overwrite`, but confirm first. 197 + 4. **Never use `--overwrite` without the user's explicit approval.** 198 + 199 + ## Quick Reference 200 + 201 + ```bash 202 + # Create a new project. 203 + npx shadcn@latest init --name my-app --preset base-nova 204 + npx shadcn@latest init --name my-app --preset a2r6bw --template vite 205 + 206 + # Create a monorepo project. 207 + npx shadcn@latest init --name my-app --preset base-nova --monorepo 208 + npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo 209 + 210 + # Initialize existing project. 211 + npx shadcn@latest init --preset base-nova 212 + npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied) 213 + 214 + # Apply a preset to an existing project. 215 + npx shadcn@latest apply a2r6bw 216 + npx shadcn@latest apply a2r6bw --only theme 217 + npx shadcn@latest apply a2r6bw --only font 218 + npx shadcn@latest apply a2r6bw --only theme,font 219 + 220 + # Inspect preset codes and project preset state. 221 + npx shadcn@latest preset decode a2r6bw 222 + npx shadcn@latest preset url a2r6bw 223 + npx shadcn@latest preset open a2r6bw 224 + npx shadcn@latest preset resolve 225 + npx shadcn@latest preset resolve --json 226 + 227 + # Add components. 228 + npx shadcn@latest add button card dialog 229 + npx shadcn@latest add @magicui/shimmer-button 230 + npx shadcn@latest add --all 231 + 232 + # Preview changes before adding/updating. 233 + npx shadcn@latest add button --dry-run 234 + npx shadcn@latest add button --diff button.tsx 235 + npx shadcn@latest add @acme/form --view button.tsx 236 + 237 + # Search registries. 238 + npx shadcn@latest search @shadcn -q "sidebar" 239 + npx shadcn@latest search @tailark -q "stats" 240 + 241 + # Get component docs and example URLs. 242 + npx shadcn@latest docs button dialog select 243 + 244 + # View registry item details (for items not yet installed). 245 + npx shadcn@latest view @shadcn/button 246 + ``` 247 + 248 + **Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma` 249 + **Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo) 250 + **Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com). 251 + 252 + ## Detailed References 253 + 254 + - [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states 255 + - [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading 256 + - [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects 257 + - [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index 258 + - [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion 259 + - [cli.md](./cli.md) — Commands, flags, presets, templates 260 + - [customization.md](./customization.md) — Theming, CSS variables, extending components
+5
.agents/skills/shadcn/agents/openai.yml
··· 1 + interface: 2 + display_name: "shadcn/ui" 3 + short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI." 4 + icon_small: "./assets/shadcn-small.png" 5 + icon_large: "./assets/shadcn.png"
.agents/skills/shadcn/assets/shadcn-small.png

This is a binary file and will not be displayed.

.agents/skills/shadcn/assets/shadcn.png

This is a binary file and will not be displayed.

+276
.agents/skills/shadcn/cli.md
··· 1 + # shadcn CLI Reference 2 + 3 + Configuration is read from `components.json`. 4 + 5 + > **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. 6 + 7 + > **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag. 8 + 9 + ## Contents 10 + 11 + - Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build 12 + - Templates: next, vite, start, react-router, astro 13 + - Presets: named, code, URL formats and fields 14 + - Switching presets 15 + 16 + --- 17 + 18 + ## Commands 19 + 20 + ### `init` — Initialize or create a project 21 + 22 + ```bash 23 + npx shadcn@latest init [components...] [options] 24 + ``` 25 + 26 + Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step. 27 + 28 + | Flag | Short | Description | Default | 29 + | ----------------------- | ----- | --------------------------------------------------------- | ------- | 30 + | `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — | 31 + | `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — | 32 + | `--yes` | `-y` | Skip confirmation prompt | `true` | 33 + | `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` | 34 + | `--force` | `-f` | Force overwrite existing configuration | `false` | 35 + | `--cwd <cwd>` | `-c` | Working directory | current | 36 + | `--name <name>` | `-n` | Name for new project | — | 37 + | `--silent` | `-s` | Mute output | `false` | 38 + | `--rtl` | | Enable RTL support | — | 39 + | `--reinstall` | | Re-install existing UI components | `false` | 40 + | `--monorepo` | | Scaffold a monorepo project | — | 41 + | `--no-monorepo` | | Skip the monorepo prompt | — | 42 + 43 + `npx shadcn@latest create` is an alias for `npx shadcn@latest init`. 44 + 45 + ### `apply` — Apply a preset to an existing project 46 + 47 + ```bash 48 + npx shadcn@latest apply [preset] [options] 49 + ``` 50 + 51 + Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components. 52 + 53 + | Flag | Short | Description | Default | 54 + | ------------------- | ----- | ------------------------------------------ | ------- | 55 + | `--preset <preset>` | — | Preset configuration (named, code, or URL) | — | 56 + | `--yes` | `-y` | Skip confirmation prompt | `false` | 57 + | `--cwd <cwd>` | `-c` | Working directory | current | 58 + | `--silent` | `-s` | Mute output | `false` | 59 + 60 + `[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match. 61 + If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`. 62 + 63 + ### `add` — Add components 64 + 65 + > **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically. 66 + 67 + ```bash 68 + npx shadcn@latest add [components...] [options] 69 + ``` 70 + 71 + Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths. 72 + 73 + | Flag | Short | Description | Default | 74 + | --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- | 75 + | `--yes` | `-y` | Skip confirmation prompt | `false` | 76 + | `--overwrite` | `-o` | Overwrite existing files | `false` | 77 + | `--cwd <cwd>` | `-c` | Working directory | current | 78 + | `--all` | `-a` | Add all available components | `false` | 79 + | `--path <path>` | `-p` | Target path for the component | — | 80 + | `--silent` | `-s` | Mute output | `false` | 81 + | `--dry-run` | | Preview all changes without writing files | `false` | 82 + | `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — | 83 + | `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — | 84 + 85 + #### Dry-Run Mode 86 + 87 + Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`. 88 + 89 + ```bash 90 + # Preview all changes. 91 + npx shadcn@latest add button --dry-run 92 + 93 + # Show diffs for all files (top 5). 94 + npx shadcn@latest add button --diff 95 + 96 + # Show the diff for a specific file. 97 + npx shadcn@latest add button --diff button.tsx 98 + 99 + # Show contents for all files (top 5). 100 + npx shadcn@latest add button --view 101 + 102 + # Show the full content of a specific file. 103 + npx shadcn@latest add button --view button.tsx 104 + 105 + # Works with URLs too. 106 + npx shadcn@latest add https://api.npoint.io/abc123 --dry-run 107 + 108 + # CSS diffs. 109 + npx shadcn@latest add button --diff globals.css 110 + ``` 111 + 112 + **When to use dry-run:** 113 + 114 + - When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`. 115 + - Before overwriting existing components — use `--diff` to preview the changes first. 116 + - When the user wants to inspect component source code without installing — use `--view`. 117 + - When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`. 118 + - When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source. 119 + 120 + > **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context. 121 + 122 + #### Smart Merge from Upstream 123 + 124 + See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow. 125 + 126 + ### `search` — Search registries 127 + 128 + ```bash 129 + npx shadcn@latest search <registries...> [options] 130 + ``` 131 + 132 + Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items. 133 + 134 + | Flag | Short | Description | Default | 135 + | ------------------- | ----- | ---------------------- | ------- | 136 + | `--query <query>` | `-q` | Search query | — | 137 + | `--limit <number>` | `-l` | Max items per registry | `100` | 138 + | `--offset <number>` | `-o` | Items to skip | `0` | 139 + | `--cwd <cwd>` | `-c` | Working directory | current | 140 + 141 + ### `view` — View item details 142 + 143 + ```bash 144 + npx shadcn@latest view <items...> [options] 145 + ``` 146 + 147 + Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`. 148 + 149 + ### `docs` — Get component documentation URLs 150 + 151 + ```bash 152 + npx shadcn@latest docs <components...> [options] 153 + ``` 154 + 155 + Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content. 156 + 157 + Example output for `npx shadcn@latest docs input button`: 158 + 159 + ``` 160 + base radix 161 + 162 + input 163 + docs https://ui.shadcn.com/docs/components/radix/input 164 + examples https://raw.githubusercontent.com/.../examples/input-example.tsx 165 + 166 + button 167 + docs https://ui.shadcn.com/docs/components/radix/button 168 + examples https://raw.githubusercontent.com/.../examples/button-example.tsx 169 + ``` 170 + 171 + Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component). 172 + 173 + ### `diff` — Check for updates 174 + 175 + Do not use this command. Use `npx shadcn@latest add --diff` instead. 176 + 177 + ### `info` — Project information 178 + 179 + ```bash 180 + npx shadcn@latest info [options] 181 + ``` 182 + 183 + Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths. 184 + 185 + | Flag | Short | Description | Default | 186 + | ------------- | ----- | ----------------- | ------- | 187 + | `--cwd <cwd>` | `-c` | Working directory | current | 188 + 189 + **Project Info fields:** 190 + 191 + | Field | Type | Meaning | 192 + | -------------------- | --------- | ------------------------------------------------------------------ | 193 + | `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) | 194 + | `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) | 195 + | `isSrcDir` | `boolean` | Whether the project uses a `src/` directory | 196 + | `isRSC` | `boolean` | Whether React Server Components are enabled | 197 + | `isTsx` | `boolean` | Whether the project uses TypeScript | 198 + | `tailwindVersion` | `string` | `"v3"` or `"v4"` | 199 + | `tailwindConfigFile` | `string` | Path to the Tailwind config file | 200 + | `tailwindCssFile` | `string` | Path to the global CSS file | 201 + | `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) | 202 + | `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) | 203 + 204 + **Components.json fields:** 205 + 206 + | Field | Type | Meaning | 207 + | -------------------- | --------- | ------------------------------------------------------------------------------------------ | 208 + | `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props | 209 + | `style` | `string` | Visual style (e.g. `nova`, `vega`) | 210 + | `rsc` | `boolean` | RSC flag from config | 211 + | `tsx` | `boolean` | TypeScript flag | 212 + | `tailwind.config` | `string` | Tailwind config path | 213 + | `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go | 214 + | `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) | 215 + | `aliases.components` | `string` | Component import alias (e.g. `@/components`) | 216 + | `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) | 217 + | `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) | 218 + | `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) | 219 + | `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) | 220 + | `resolvedPaths` | `object` | Absolute file-system paths for each alias | 221 + | `registries` | `object` | Configured custom registries | 222 + 223 + **Links fields:** 224 + 225 + The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead. 226 + 227 + ### `build` — Build a custom registry 228 + 229 + ```bash 230 + npx shadcn@latest build [registry] [options] 231 + ``` 232 + 233 + Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`. 234 + 235 + | Flag | Short | Description | Default | 236 + | ----------------- | ----- | ----------------- | ------------ | 237 + | `--output <path>` | `-o` | Output directory | `./public/r` | 238 + | `--cwd <cwd>` | `-c` | Working directory | current | 239 + 240 + --- 241 + 242 + ## Templates 243 + 244 + | Value | Framework | Monorepo support | 245 + | -------------- | -------------- | ---------------- | 246 + | `next` | Next.js | Yes | 247 + | `vite` | Vite | Yes | 248 + | `start` | TanStack Start | Yes | 249 + | `react-router` | React Router | Yes | 250 + | `astro` | Astro | Yes | 251 + | `laravel` | Laravel | No | 252 + 253 + All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding. 254 + 255 + --- 256 + 257 + ## Presets 258 + 259 + Three ways to specify a preset via `--preset`: 260 + 261 + 1. **Named:** `--preset nova` or `--preset lyra` 262 + 2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`) 263 + 3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."` 264 + 265 + > **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution. 266 + > Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset. 267 + 268 + ## Switching Presets 269 + 270 + Ask the user first: **overwrite**, **merge**, or **skip** existing components? 271 + 272 + - **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components. 273 + - **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components. 274 + - **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is. 275 + 276 + Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
+209
.agents/skills/shadcn/customization.md
··· 1 + # Customization & Theming 2 + 3 + Components reference semantic CSS variable tokens. Change the variables to change every component. 4 + 5 + ## Contents 6 + 7 + - How it works (CSS variables → Tailwind utilities → components) 8 + - Color variables and OKLCH format 9 + - Dark mode setup 10 + - Changing the theme (presets, CSS variables) 11 + - Adding custom colors (Tailwind v3 and v4) 12 + - Border radius 13 + - Customizing components (variants, className, wrappers) 14 + - Checking for updates 15 + 16 + --- 17 + 18 + ## How It Works 19 + 20 + 1. CSS variables defined in `:root` (light) and `.dark` (dark mode). 21 + 2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc. 22 + 3. Components use these utilities — changing a variable changes all components that reference it. 23 + 24 + --- 25 + 26 + ## Color Variables 27 + 28 + Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background. 29 + 30 + | Variable | Purpose | 31 + | -------------------------------------------- | -------------------------------- | 32 + | `--background` / `--foreground` | Page background and default text | 33 + | `--card` / `--card-foreground` | Card surfaces | 34 + | `--primary` / `--primary-foreground` | Primary buttons and actions | 35 + | `--secondary` / `--secondary-foreground` | Secondary actions | 36 + | `--muted` / `--muted-foreground` | Muted/disabled states | 37 + | `--accent` / `--accent-foreground` | Hover and accent states | 38 + | `--destructive` / `--destructive-foreground` | Error and destructive actions | 39 + | `--border` | Default border color | 40 + | `--input` | Form input borders | 41 + | `--ring` | Focus ring color | 42 + | `--chart-1` through `--chart-5` | Chart/data visualization | 43 + | `--sidebar-*` | Sidebar-specific colors | 44 + | `--surface` / `--surface-foreground` | Secondary surface | 45 + 46 + Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360). 47 + 48 + --- 49 + 50 + ## Dark Mode 51 + 52 + Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`: 53 + 54 + ```tsx 55 + import { ThemeProvider } from "next-themes" 56 + 57 + <ThemeProvider attribute="class" defaultTheme="system" enableSystem> 58 + {children} 59 + </ThemeProvider> 60 + ``` 61 + 62 + --- 63 + 64 + ## Changing the Theme 65 + 66 + ```bash 67 + # Apply a preset code from ui.shadcn.com. 68 + npx shadcn@latest apply --preset a2r6bw 69 + 70 + # Positional shorthand also works. 71 + npx shadcn@latest apply a2r6bw 72 + 73 + # Switch to a named preset and overwrite existing components. 74 + npx shadcn@latest apply --preset nova 75 + 76 + # Preserve existing components instead. 77 + npx shadcn@latest init --preset nova --force --no-reinstall 78 + 79 + # Use a custom theme URL. 80 + npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." 81 + ``` 82 + 83 + Or edit CSS variables directly in `globals.css`. 84 + 85 + --- 86 + 87 + ## Adding Custom Colors 88 + 89 + Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this. 90 + 91 + ```css 92 + /* 1. Define in the global CSS file. */ 93 + :root { 94 + --warning: oklch(0.84 0.16 84); 95 + --warning-foreground: oklch(0.28 0.07 46); 96 + } 97 + .dark { 98 + --warning: oklch(0.41 0.11 46); 99 + --warning-foreground: oklch(0.99 0.02 95); 100 + } 101 + ``` 102 + 103 + ```css 104 + /* 2a. Register with Tailwind v4 (@theme inline). */ 105 + @theme inline { 106 + --color-warning: var(--warning); 107 + --color-warning-foreground: var(--warning-foreground); 108 + } 109 + ``` 110 + 111 + When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead: 112 + 113 + ```js 114 + // 2b. Register with Tailwind v3 (tailwind.config.js). 115 + module.exports = { 116 + theme: { 117 + extend: { 118 + colors: { 119 + warning: "oklch(var(--warning) / <alpha-value>)", 120 + "warning-foreground": 121 + "oklch(var(--warning-foreground) / <alpha-value>)", 122 + }, 123 + }, 124 + }, 125 + } 126 + ``` 127 + 128 + ```tsx 129 + // 3. Use in components. 130 + <div className="bg-warning text-warning-foreground">Warning</div> 131 + ``` 132 + 133 + --- 134 + 135 + ## Border Radius 136 + 137 + `--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`). 138 + 139 + --- 140 + 141 + ## Customizing Components 142 + 143 + See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples. 144 + 145 + Prefer these approaches in order: 146 + 147 + ### 1. Built-in variants 148 + 149 + ```tsx 150 + <Button variant="outline" size="sm"> 151 + Click 152 + </Button> 153 + ``` 154 + 155 + ### 2. Tailwind classes via `className` 156 + 157 + ```tsx 158 + <Card className="mx-auto max-w-md">...</Card> 159 + ``` 160 + 161 + ### 3. Add a new variant 162 + 163 + Edit the component source to add a variant via `cva`: 164 + 165 + ```tsx 166 + // components/ui/button.tsx 167 + warning: "bg-warning text-warning-foreground hover:bg-warning/90", 168 + ``` 169 + 170 + ### 4. Wrapper components 171 + 172 + Compose shadcn/ui primitives into higher-level components: 173 + 174 + ```tsx 175 + export function ConfirmDialog({ title, description, onConfirm, children }) { 176 + return ( 177 + <AlertDialog> 178 + <AlertDialogTrigger asChild>{children}</AlertDialogTrigger> 179 + <AlertDialogContent> 180 + <AlertDialogHeader> 181 + <AlertDialogTitle>{title}</AlertDialogTitle> 182 + <AlertDialogDescription>{description}</AlertDialogDescription> 183 + </AlertDialogHeader> 184 + <AlertDialogFooter> 185 + <AlertDialogCancel>Cancel</AlertDialogCancel> 186 + <AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction> 187 + </AlertDialogFooter> 188 + </AlertDialogContent> 189 + </AlertDialog> 190 + ) 191 + } 192 + ``` 193 + 194 + --- 195 + 196 + ## Checking for Updates 197 + 198 + ```bash 199 + npx shadcn@latest add button --diff 200 + ``` 201 + 202 + To preview exactly what would change before updating, use `--dry-run` and `--diff`: 203 + 204 + ```bash 205 + npx shadcn@latest add button --dry-run # see all affected files 206 + npx shadcn@latest add button --diff button.tsx # see the diff for a specific file 207 + ``` 208 + 209 + See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
+47
.agents/skills/shadcn/evals/evals.json
··· 1 + { 2 + "skill_name": "shadcn", 3 + "evals": [ 4 + { 5 + "id": 1, 6 + "prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.", 7 + "expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.", 8 + "files": [], 9 + "expectations": [ 10 + "Uses FieldGroup and Field components for form layout instead of raw div with space-y", 11 + "Uses Switch for independent on/off notification toggles (not looping Button with manual active state)", 12 + "Uses data-invalid on Field and aria-invalid on the input control for validation states", 13 + "Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing", 14 + "Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500", 15 + "No manual dark: color overrides" 16 + ] 17 + }, 18 + { 19 + "id": 2, 20 + "prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.", 21 + "expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.", 22 + "files": [], 23 + "expectations": [ 24 + "Includes DialogTitle for accessibility (visible or with sr-only class)", 25 + "Avatar component includes AvatarFallback", 26 + "Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")", 27 + "No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)", 28 + "Uses tabler icons (@tabler/icons-react) instead of lucide-react", 29 + "Uses asChild for custom triggers (radix preset)" 30 + ] 31 + }, 32 + { 33 + "id": 3, 34 + "prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.", 35 + "expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.", 36 + "files": [], 37 + "expectations": [ 38 + "Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)", 39 + "Uses Skeleton component for loading placeholders instead of custom animate-pulse divs", 40 + "Uses Badge component for percentage change instead of custom styled spans", 41 + "Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600", 42 + "Uses gap-* instead of space-y-* or space-x-* for spacing", 43 + "Uses size-* when width and height are equal instead of separate w-* h-*" 44 + ] 45 + } 46 + ] 47 + }
+94
.agents/skills/shadcn/mcp.md
··· 1 + # shadcn MCP Server 2 + 3 + The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries. 4 + 5 + --- 6 + 7 + ## Setup 8 + 9 + ```bash 10 + shadcn mcp # start the MCP server (stdio) 11 + shadcn mcp init # write config for your editor 12 + ``` 13 + 14 + Editor config files: 15 + 16 + | Editor | Config file | 17 + |--------|------------| 18 + | Claude Code | `.mcp.json` | 19 + | Cursor | `.cursor/mcp.json` | 20 + | VS Code | `.vscode/mcp.json` | 21 + | OpenCode | `opencode.json` | 22 + | Codex | `~/.codex/config.toml` (manual) | 23 + 24 + --- 25 + 26 + ## Tools 27 + 28 + > **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent. 29 + 30 + ### `shadcn:get_project_registries` 31 + 32 + Returns registry names from `components.json`. Errors if no `components.json` exists. 33 + 34 + **Input:** none 35 + 36 + ### `shadcn:list_items_in_registries` 37 + 38 + Lists all items from one or more registries. 39 + 40 + **Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional) 41 + 42 + ### `shadcn:search_items_in_registries` 43 + 44 + Fuzzy search across registries. 45 + 46 + **Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional) 47 + 48 + ### `shadcn:view_items_in_registries` 49 + 50 + View item details including full file contents. 51 + 52 + **Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]` 53 + 54 + ### `shadcn:get_item_examples_from_registries` 55 + 56 + Find usage examples and demos with source code. 57 + 58 + **Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"` 59 + 60 + ### `shadcn:get_add_command_for_items` 61 + 62 + Returns the CLI install command. 63 + 64 + **Input:** `items` (string[]) — e.g. `["@shadcn/button"]` 65 + 66 + ### `shadcn:get_audit_checklist` 67 + 68 + Returns a checklist for verifying components (imports, deps, lint, TypeScript). 69 + 70 + **Input:** none 71 + 72 + --- 73 + 74 + ## Configuring Registries 75 + 76 + Registries are set in `components.json`. The `@shadcn` registry is always built-in. 77 + 78 + ```json 79 + { 80 + "registries": { 81 + "@acme": "https://acme.com/r/{name}.json", 82 + "@private": { 83 + "url": "https://private.com/r/{name}.json", 84 + "headers": { "Authorization": "Bearer ${MY_TOKEN}" } 85 + } 86 + } 87 + } 88 + ``` 89 + 90 + - Names must start with `@`. 91 + - URLs must contain `{name}`. 92 + - `${VAR}` references are resolved from environment variables. 93 + 94 + Community registry index: `https://ui.shadcn.com/r/registries.json`
+306
.agents/skills/shadcn/rules/base-vs-radix.md
··· 1 + # Base vs Radix 2 + 3 + API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`. 4 + 5 + ## Contents 6 + 7 + - Composition: asChild vs render 8 + - Button / trigger as non-button element 9 + - Select (items prop, placeholder, positioning, multiple, object values) 10 + - ToggleGroup (type vs multiple) 11 + - Slider (scalar vs array) 12 + - Accordion (type and defaultValue) 13 + 14 + --- 15 + 16 + ## Composition: asChild (radix) vs render (base) 17 + 18 + Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements. 19 + 20 + **Incorrect:** 21 + 22 + ```tsx 23 + <DialogTrigger> 24 + <div> 25 + <Button>Open</Button> 26 + </div> 27 + </DialogTrigger> 28 + ``` 29 + 30 + **Correct (radix):** 31 + 32 + ```tsx 33 + <DialogTrigger asChild> 34 + <Button>Open</Button> 35 + </DialogTrigger> 36 + ``` 37 + 38 + **Correct (base):** 39 + 40 + ```tsx 41 + <DialogTrigger render={<Button />}>Open</DialogTrigger> 42 + ``` 43 + 44 + This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`. 45 + 46 + --- 47 + 48 + ## Button / trigger as non-button element (base only) 49 + 50 + When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`. 51 + 52 + **Incorrect (base):** missing `nativeButton={false}`. 53 + 54 + ```tsx 55 + <Button render={<a href="/docs" />}>Read the docs</Button> 56 + ``` 57 + 58 + **Correct (base):** 59 + 60 + ```tsx 61 + <Button render={<a href="/docs" />} nativeButton={false}> 62 + Read the docs 63 + </Button> 64 + ``` 65 + 66 + **Correct (radix):** 67 + 68 + ```tsx 69 + <Button asChild> 70 + <a href="/docs">Read the docs</a> 71 + </Button> 72 + ``` 73 + 74 + Same for triggers whose `render` is not a `Button`: 75 + 76 + ```tsx 77 + // base. 78 + <PopoverTrigger render={<InputGroupAddon />} nativeButton={false}> 79 + Pick date 80 + </PopoverTrigger> 81 + ``` 82 + 83 + --- 84 + 85 + ## Select 86 + 87 + **items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only. 88 + 89 + **Incorrect (base):** 90 + 91 + ```tsx 92 + <Select> 93 + <SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger> 94 + </Select> 95 + ``` 96 + 97 + **Correct (base):** 98 + 99 + ```tsx 100 + const items = [ 101 + { label: "Select a fruit", value: null }, 102 + { label: "Apple", value: "apple" }, 103 + { label: "Banana", value: "banana" }, 104 + ] 105 + 106 + <Select items={items}> 107 + <SelectTrigger> 108 + <SelectValue /> 109 + </SelectTrigger> 110 + <SelectContent> 111 + <SelectGroup> 112 + {items.map((item) => ( 113 + <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem> 114 + ))} 115 + </SelectGroup> 116 + </SelectContent> 117 + </Select> 118 + ``` 119 + 120 + **Correct (radix):** 121 + 122 + ```tsx 123 + <Select> 124 + <SelectTrigger> 125 + <SelectValue placeholder="Select a fruit" /> 126 + </SelectTrigger> 127 + <SelectContent> 128 + <SelectGroup> 129 + <SelectItem value="apple">Apple</SelectItem> 130 + <SelectItem value="banana">Banana</SelectItem> 131 + </SelectGroup> 132 + </SelectContent> 133 + </Select> 134 + ``` 135 + 136 + **Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`. 137 + 138 + **Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`. 139 + 140 + ```tsx 141 + // base. 142 + <SelectContent alignItemWithTrigger={false} side="bottom"> 143 + 144 + // radix. 145 + <SelectContent position="popper"> 146 + ``` 147 + 148 + --- 149 + 150 + ## Select — multiple selection and object values (base only) 151 + 152 + Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only. 153 + 154 + **Correct (base — multiple selection):** 155 + 156 + ```tsx 157 + <Select items={items} multiple defaultValue={[]}> 158 + <SelectTrigger> 159 + <SelectValue> 160 + {(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`} 161 + </SelectValue> 162 + </SelectTrigger> 163 + ... 164 + </Select> 165 + ``` 166 + 167 + **Correct (base — object values):** 168 + 169 + ```tsx 170 + <Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}> 171 + <SelectTrigger> 172 + <SelectValue>{(value) => value.name}</SelectValue> 173 + </SelectTrigger> 174 + ... 175 + </Select> 176 + ``` 177 + 178 + --- 179 + 180 + ## ToggleGroup 181 + 182 + Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`. 183 + 184 + **Incorrect (base):** 185 + 186 + ```tsx 187 + <ToggleGroup type="single" defaultValue="daily"> 188 + <ToggleGroupItem value="daily">Daily</ToggleGroupItem> 189 + </ToggleGroup> 190 + ``` 191 + 192 + **Correct (base):** 193 + 194 + ```tsx 195 + // Single (no prop needed), defaultValue is always an array. 196 + <ToggleGroup defaultValue={["daily"]} spacing={2}> 197 + <ToggleGroupItem value="daily">Daily</ToggleGroupItem> 198 + <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem> 199 + </ToggleGroup> 200 + 201 + // Multi-selection. 202 + <ToggleGroup multiple> 203 + <ToggleGroupItem value="bold">Bold</ToggleGroupItem> 204 + <ToggleGroupItem value="italic">Italic</ToggleGroupItem> 205 + </ToggleGroup> 206 + ``` 207 + 208 + **Correct (radix):** 209 + 210 + ```tsx 211 + // Single, defaultValue is a string. 212 + <ToggleGroup type="single" defaultValue="daily" spacing={2}> 213 + <ToggleGroupItem value="daily">Daily</ToggleGroupItem> 214 + <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem> 215 + </ToggleGroup> 216 + 217 + // Multi-selection. 218 + <ToggleGroup type="multiple"> 219 + <ToggleGroupItem value="bold">Bold</ToggleGroupItem> 220 + <ToggleGroupItem value="italic">Italic</ToggleGroupItem> 221 + </ToggleGroup> 222 + ``` 223 + 224 + **Controlled single value:** 225 + 226 + ```tsx 227 + // base — wrap/unwrap arrays. 228 + const [value, setValue] = React.useState("normal") 229 + <ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}> 230 + 231 + // radix — plain string. 232 + const [value, setValue] = React.useState("normal") 233 + <ToggleGroup type="single" value={value} onValueChange={setValue}> 234 + ``` 235 + 236 + --- 237 + 238 + ## Slider 239 + 240 + Base accepts a plain number for a single thumb. Radix always requires an array. 241 + 242 + **Incorrect (base):** 243 + 244 + ```tsx 245 + <Slider defaultValue={[50]} max={100} step={1} /> 246 + ``` 247 + 248 + **Correct (base):** 249 + 250 + ```tsx 251 + <Slider defaultValue={50} max={100} step={1} /> 252 + ``` 253 + 254 + **Correct (radix):** 255 + 256 + ```tsx 257 + <Slider defaultValue={[50]} max={100} step={1} /> 258 + ``` 259 + 260 + Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast: 261 + 262 + ```tsx 263 + // base. 264 + const [value, setValue] = React.useState([0.3, 0.7]) 265 + <Slider value={value} onValueChange={(v) => setValue(v as number[])} /> 266 + 267 + // radix. 268 + const [value, setValue] = React.useState([0.3, 0.7]) 269 + <Slider value={value} onValueChange={setValue} /> 270 + ``` 271 + 272 + --- 273 + 274 + ## Accordion 275 + 276 + Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array. 277 + 278 + **Incorrect (base):** 279 + 280 + ```tsx 281 + <Accordion type="single" collapsible defaultValue="item-1"> 282 + <AccordionItem value="item-1">...</AccordionItem> 283 + </Accordion> 284 + ``` 285 + 286 + **Correct (base):** 287 + 288 + ```tsx 289 + <Accordion defaultValue={["item-1"]}> 290 + <AccordionItem value="item-1">...</AccordionItem> 291 + </Accordion> 292 + 293 + // Multi-select. 294 + <Accordion multiple defaultValue={["item-1", "item-2"]}> 295 + <AccordionItem value="item-1">...</AccordionItem> 296 + <AccordionItem value="item-2">...</AccordionItem> 297 + </Accordion> 298 + ``` 299 + 300 + **Correct (radix):** 301 + 302 + ```tsx 303 + <Accordion type="single" collapsible defaultValue="item-1"> 304 + <AccordionItem value="item-1">...</AccordionItem> 305 + </Accordion> 306 + ```
+195
.agents/skills/shadcn/rules/composition.md
··· 1 + # Component Composition 2 + 3 + ## Contents 4 + 5 + - Items always inside their Group component 6 + - Callouts use Alert 7 + - Empty states use Empty component 8 + - Toast notifications use sonner 9 + - Choosing between overlay components 10 + - Dialog, Sheet, and Drawer always need a Title 11 + - Card structure 12 + - Button has no isPending or isLoading prop 13 + - TabsTrigger must be inside TabsList 14 + - Avatar always needs AvatarFallback 15 + - Use Separator instead of raw hr or border divs 16 + - Use Skeleton for loading placeholders 17 + - Use Badge instead of custom styled spans 18 + 19 + --- 20 + 21 + ## Items always inside their Group component 22 + 23 + Never render items directly inside the content container. 24 + 25 + **Incorrect:** 26 + 27 + ```tsx 28 + <SelectContent> 29 + <SelectItem value="apple">Apple</SelectItem> 30 + <SelectItem value="banana">Banana</SelectItem> 31 + </SelectContent> 32 + ``` 33 + 34 + **Correct:** 35 + 36 + ```tsx 37 + <SelectContent> 38 + <SelectGroup> 39 + <SelectItem value="apple">Apple</SelectItem> 40 + <SelectItem value="banana">Banana</SelectItem> 41 + </SelectGroup> 42 + </SelectContent> 43 + ``` 44 + 45 + This applies to all group-based components: 46 + 47 + | Item | Group | 48 + |------|-------| 49 + | `SelectItem`, `SelectLabel` | `SelectGroup` | 50 + | `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` | 51 + | `MenubarItem` | `MenubarGroup` | 52 + | `ContextMenuItem` | `ContextMenuGroup` | 53 + | `CommandItem` | `CommandGroup` | 54 + 55 + --- 56 + 57 + ## Callouts use Alert 58 + 59 + ```tsx 60 + <Alert> 61 + <AlertTitle>Warning</AlertTitle> 62 + <AlertDescription>Something needs attention.</AlertDescription> 63 + </Alert> 64 + ``` 65 + 66 + --- 67 + 68 + ## Empty states use Empty component 69 + 70 + ```tsx 71 + <Empty> 72 + <EmptyHeader> 73 + <EmptyMedia variant="icon"><FolderIcon /></EmptyMedia> 74 + <EmptyTitle>No projects yet</EmptyTitle> 75 + <EmptyDescription>Get started by creating a new project.</EmptyDescription> 76 + </EmptyHeader> 77 + <EmptyContent> 78 + <Button>Create Project</Button> 79 + </EmptyContent> 80 + </Empty> 81 + ``` 82 + 83 + --- 84 + 85 + ## Toast notifications use sonner 86 + 87 + ```tsx 88 + import { toast } from "sonner" 89 + 90 + toast.success("Changes saved.") 91 + toast.error("Something went wrong.") 92 + toast("File deleted.", { 93 + action: { label: "Undo", onClick: () => undoDelete() }, 94 + }) 95 + ``` 96 + 97 + --- 98 + 99 + ## Choosing between overlay components 100 + 101 + | Use case | Component | 102 + |----------|-----------| 103 + | Focused task that requires input | `Dialog` | 104 + | Destructive action confirmation | `AlertDialog` | 105 + | Side panel with details or filters | `Sheet` | 106 + | Mobile-first bottom panel | `Drawer` | 107 + | Quick info on hover | `HoverCard` | 108 + | Small contextual content on click | `Popover` | 109 + 110 + --- 111 + 112 + ## Dialog, Sheet, and Drawer always need a Title 113 + 114 + `DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden. 115 + 116 + ```tsx 117 + <DialogContent> 118 + <DialogHeader> 119 + <DialogTitle>Edit Profile</DialogTitle> 120 + <DialogDescription>Update your profile.</DialogDescription> 121 + </DialogHeader> 122 + ... 123 + </DialogContent> 124 + ``` 125 + 126 + --- 127 + 128 + ## Card structure 129 + 130 + Use full composition — don't dump everything into `CardContent`: 131 + 132 + ```tsx 133 + <Card> 134 + <CardHeader> 135 + <CardTitle>Team Members</CardTitle> 136 + <CardDescription>Manage your team.</CardDescription> 137 + </CardHeader> 138 + <CardContent>...</CardContent> 139 + <CardFooter> 140 + <Button>Invite</Button> 141 + </CardFooter> 142 + </Card> 143 + ``` 144 + 145 + --- 146 + 147 + ## Button has no isPending or isLoading prop 148 + 149 + Compose with `Spinner` + `data-icon` + `disabled`: 150 + 151 + ```tsx 152 + <Button disabled> 153 + <Spinner data-icon="inline-start" /> 154 + Saving... 155 + </Button> 156 + ``` 157 + 158 + --- 159 + 160 + ## TabsTrigger must be inside TabsList 161 + 162 + Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`: 163 + 164 + ```tsx 165 + <Tabs defaultValue="account"> 166 + <TabsList> 167 + <TabsTrigger value="account">Account</TabsTrigger> 168 + <TabsTrigger value="password">Password</TabsTrigger> 169 + </TabsList> 170 + <TabsContent value="account">...</TabsContent> 171 + </Tabs> 172 + ``` 173 + 174 + --- 175 + 176 + ## Avatar always needs AvatarFallback 177 + 178 + Always include `AvatarFallback` for when the image fails to load: 179 + 180 + ```tsx 181 + <Avatar> 182 + <AvatarImage src="/avatar.png" alt="User" /> 183 + <AvatarFallback>JD</AvatarFallback> 184 + </Avatar> 185 + ``` 186 + 187 + --- 188 + 189 + ## Use existing components instead of custom markup 190 + 191 + | Instead of | Use | 192 + |---|---| 193 + | `<hr>` or `<div className="border-t">` | `<Separator />` | 194 + | `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` | 195 + | `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
+192
.agents/skills/shadcn/rules/forms.md
··· 1 + # Forms & Inputs 2 + 3 + ## Contents 4 + 5 + - Forms use FieldGroup + Field 6 + - InputGroup requires InputGroupInput/InputGroupTextarea 7 + - Buttons inside inputs use InputGroup + InputGroupAddon 8 + - Option sets (2–7 choices) use ToggleGroup 9 + - FieldSet + FieldLegend for grouping related fields 10 + - Field validation and disabled states 11 + 12 + --- 13 + 14 + ## Forms use FieldGroup + Field 15 + 16 + Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`: 17 + 18 + ```tsx 19 + <FieldGroup> 20 + <Field> 21 + <FieldLabel htmlFor="email">Email</FieldLabel> 22 + <Input id="email" type="email" /> 23 + </Field> 24 + <Field> 25 + <FieldLabel htmlFor="password">Password</FieldLabel> 26 + <Input id="password" type="password" /> 27 + </Field> 28 + </FieldGroup> 29 + ``` 30 + 31 + Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels. 32 + 33 + **Choosing form controls:** 34 + 35 + - Simple text input → `Input` 36 + - Dropdown with predefined options → `Select` 37 + - Searchable dropdown → `Combobox` 38 + - Native HTML select (no JS) → `native-select` 39 + - Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms) 40 + - Single choice from few options → `RadioGroup` 41 + - Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem` 42 + - OTP/verification code → `InputOTP` 43 + - Multi-line text → `Textarea` 44 + 45 + --- 46 + 47 + ## InputGroup requires InputGroupInput/InputGroupTextarea 48 + 49 + Never use raw `Input` or `Textarea` inside an `InputGroup`. 50 + 51 + **Incorrect:** 52 + 53 + ```tsx 54 + <InputGroup> 55 + <Input placeholder="Search..." /> 56 + </InputGroup> 57 + ``` 58 + 59 + **Correct:** 60 + 61 + ```tsx 62 + import { InputGroup, InputGroupInput } from "@/components/ui/input-group" 63 + 64 + <InputGroup> 65 + <InputGroupInput placeholder="Search..." /> 66 + </InputGroup> 67 + ``` 68 + 69 + --- 70 + 71 + ## Buttons inside inputs use InputGroup + InputGroupAddon 72 + 73 + Never place a `Button` directly inside or adjacent to an `Input` with custom positioning. 74 + 75 + **Incorrect:** 76 + 77 + ```tsx 78 + <div className="relative"> 79 + <Input placeholder="Search..." className="pr-10" /> 80 + <Button className="absolute right-0 top-0" size="icon"> 81 + <SearchIcon /> 82 + </Button> 83 + </div> 84 + ``` 85 + 86 + **Correct:** 87 + 88 + ```tsx 89 + import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group" 90 + 91 + <InputGroup> 92 + <InputGroupInput placeholder="Search..." /> 93 + <InputGroupAddon> 94 + <Button size="icon"> 95 + <SearchIcon data-icon="inline-start" /> 96 + </Button> 97 + </InputGroupAddon> 98 + </InputGroup> 99 + ``` 100 + 101 + --- 102 + 103 + ## Option sets (2–7 choices) use ToggleGroup 104 + 105 + Don't manually loop `Button` components with active state. 106 + 107 + **Incorrect:** 108 + 109 + ```tsx 110 + const [selected, setSelected] = useState("daily") 111 + 112 + <div className="flex gap-2"> 113 + {["daily", "weekly", "monthly"].map((option) => ( 114 + <Button 115 + key={option} 116 + variant={selected === option ? "default" : "outline"} 117 + onClick={() => setSelected(option)} 118 + > 119 + {option} 120 + </Button> 121 + ))} 122 + </div> 123 + ``` 124 + 125 + **Correct:** 126 + 127 + ```tsx 128 + import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" 129 + 130 + <ToggleGroup spacing={2}> 131 + <ToggleGroupItem value="daily">Daily</ToggleGroupItem> 132 + <ToggleGroupItem value="weekly">Weekly</ToggleGroupItem> 133 + <ToggleGroupItem value="monthly">Monthly</ToggleGroupItem> 134 + </ToggleGroup> 135 + ``` 136 + 137 + Combine with `Field` for labelled toggle groups: 138 + 139 + ```tsx 140 + <Field orientation="horizontal"> 141 + <FieldTitle id="theme-label">Theme</FieldTitle> 142 + <ToggleGroup aria-labelledby="theme-label" spacing={2}> 143 + <ToggleGroupItem value="light">Light</ToggleGroupItem> 144 + <ToggleGroupItem value="dark">Dark</ToggleGroupItem> 145 + <ToggleGroupItem value="system">System</ToggleGroupItem> 146 + </ToggleGroup> 147 + </Field> 148 + ``` 149 + 150 + > **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup). 151 + 152 + --- 153 + 154 + ## FieldSet + FieldLegend for grouping related fields 155 + 156 + Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading: 157 + 158 + ```tsx 159 + <FieldSet> 160 + <FieldLegend variant="label">Preferences</FieldLegend> 161 + <FieldDescription>Select all that apply.</FieldDescription> 162 + <FieldGroup className="gap-3"> 163 + <Field orientation="horizontal"> 164 + <Checkbox id="dark" /> 165 + <FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel> 166 + </Field> 167 + </FieldGroup> 168 + </FieldSet> 169 + ``` 170 + 171 + --- 172 + 173 + ## Field validation and disabled states 174 + 175 + Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control. 176 + 177 + ```tsx 178 + // Invalid. 179 + <Field data-invalid> 180 + <FieldLabel htmlFor="email">Email</FieldLabel> 181 + <Input id="email" aria-invalid /> 182 + <FieldDescription>Invalid email address.</FieldDescription> 183 + </Field> 184 + 185 + // Disabled. 186 + <Field data-disabled> 187 + <FieldLabel htmlFor="email">Email</FieldLabel> 188 + <Input id="email" disabled /> 189 + </Field> 190 + ``` 191 + 192 + Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
+101
.agents/skills/shadcn/rules/icons.md
··· 1 + # Icons 2 + 3 + **Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`. 4 + 5 + --- 6 + 7 + ## Icons in Button use data-icon attribute 8 + 9 + Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon. 10 + 11 + **Incorrect:** 12 + 13 + ```tsx 14 + <Button> 15 + <SearchIcon className="mr-2 size-4" /> 16 + Search 17 + </Button> 18 + ``` 19 + 20 + **Correct:** 21 + 22 + ```tsx 23 + <Button> 24 + <SearchIcon data-icon="inline-start"/> 25 + Search 26 + </Button> 27 + 28 + <Button> 29 + Next 30 + <ArrowRightIcon data-icon="inline-end"/> 31 + </Button> 32 + ``` 33 + 34 + --- 35 + 36 + ## No sizing classes on icons inside components 37 + 38 + Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes. 39 + 40 + **Incorrect:** 41 + 42 + ```tsx 43 + <Button> 44 + <SearchIcon className="size-4" data-icon="inline-start" /> 45 + Search 46 + </Button> 47 + 48 + <DropdownMenuItem> 49 + <SettingsIcon className="mr-2 size-4" /> 50 + Settings 51 + </DropdownMenuItem> 52 + ``` 53 + 54 + **Correct:** 55 + 56 + ```tsx 57 + <Button> 58 + <SearchIcon data-icon="inline-start" /> 59 + Search 60 + </Button> 61 + 62 + <DropdownMenuItem> 63 + <SettingsIcon /> 64 + Settings 65 + </DropdownMenuItem> 66 + ``` 67 + 68 + --- 69 + 70 + ## Pass icons as component objects, not string keys 71 + 72 + Use `icon={CheckIcon}`, not a string key to a lookup map. 73 + 74 + **Incorrect:** 75 + 76 + ```tsx 77 + const iconMap = { 78 + check: CheckIcon, 79 + alert: AlertIcon, 80 + } 81 + 82 + function StatusBadge({ icon }: { icon: string }) { 83 + const Icon = iconMap[icon] 84 + return <Icon /> 85 + } 86 + 87 + <StatusBadge icon="check" /> 88 + ``` 89 + 90 + **Correct:** 91 + 92 + ```tsx 93 + // Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react). 94 + import { CheckIcon } from "lucide-react" 95 + 96 + function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) { 97 + return <Icon /> 98 + } 99 + 100 + <StatusBadge icon={CheckIcon} /> 101 + ```
+162
.agents/skills/shadcn/rules/styling.md
··· 1 + # Styling & Customization 2 + 3 + See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors. 4 + 5 + ## Contents 6 + 7 + - Semantic colors 8 + - Built-in variants first 9 + - className for layout only 10 + - No space-x-* / space-y-* 11 + - Prefer size-* over w-* h-* when equal 12 + - Prefer truncate shorthand 13 + - No manual dark: color overrides 14 + - Use cn() for conditional classes 15 + - No manual z-index on overlay components 16 + 17 + --- 18 + 19 + ## Semantic colors 20 + 21 + **Incorrect:** 22 + 23 + ```tsx 24 + <div className="bg-blue-500 text-white"> 25 + <p className="text-gray-600">Secondary text</p> 26 + </div> 27 + ``` 28 + 29 + **Correct:** 30 + 31 + ```tsx 32 + <div className="bg-primary text-primary-foreground"> 33 + <p className="text-muted-foreground">Secondary text</p> 34 + </div> 35 + ``` 36 + 37 + --- 38 + 39 + ## No raw color values for status/state indicators 40 + 41 + For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors. 42 + 43 + **Incorrect:** 44 + 45 + ```tsx 46 + <span className="text-emerald-600">+20.1%</span> 47 + <span className="text-green-500">Active</span> 48 + <span className="text-red-600">-3.2%</span> 49 + ``` 50 + 51 + **Correct:** 52 + 53 + ```tsx 54 + <Badge variant="secondary">+20.1%</Badge> 55 + <Badge>Active</Badge> 56 + <span className="text-destructive">-3.2%</span> 57 + ``` 58 + 59 + If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)). 60 + 61 + --- 62 + 63 + ## Built-in variants first 64 + 65 + **Incorrect:** 66 + 67 + ```tsx 68 + <Button className="border border-input bg-transparent hover:bg-accent"> 69 + Click me 70 + </Button> 71 + ``` 72 + 73 + **Correct:** 74 + 75 + ```tsx 76 + <Button variant="outline">Click me</Button> 77 + ``` 78 + 79 + --- 80 + 81 + ## className for layout only 82 + 83 + Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables. 84 + 85 + **Incorrect:** 86 + 87 + ```tsx 88 + <Card className="bg-blue-100 text-blue-900 font-bold"> 89 + <CardContent>Dashboard</CardContent> 90 + </Card> 91 + ``` 92 + 93 + **Correct:** 94 + 95 + ```tsx 96 + <Card className="max-w-md mx-auto"> 97 + <CardContent>Dashboard</CardContent> 98 + </Card> 99 + ``` 100 + 101 + To customize a component's appearance, prefer these approaches in order: 102 + 1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc. 103 + 2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`. 104 + 3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)). 105 + 106 + --- 107 + 108 + ## No space-x-* / space-y-* 109 + 110 + Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`. 111 + 112 + ```tsx 113 + <div className="flex flex-col gap-4"> 114 + <Input /> 115 + <Input /> 116 + <Button>Submit</Button> 117 + </div> 118 + ``` 119 + 120 + --- 121 + 122 + ## Prefer size-* over w-* h-* when equal 123 + 124 + `size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc. 125 + 126 + --- 127 + 128 + ## Prefer truncate shorthand 129 + 130 + `truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`. 131 + 132 + --- 133 + 134 + ## No manual dark: color overrides 135 + 136 + Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`. 137 + 138 + --- 139 + 140 + ## Use cn() for conditional classes 141 + 142 + Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings. 143 + 144 + **Incorrect:** 145 + 146 + ```tsx 147 + <div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}> 148 + ``` 149 + 150 + **Correct:** 151 + 152 + ```tsx 153 + import { cn } from "@/lib/utils" 154 + 155 + <div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}> 156 + ``` 157 + 158 + --- 159 + 160 + ## No manual z-index on overlay components 161 + 162 + `Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
-1
apps/web/src/components/Footer.tsx
··· 31 31 product: [ 32 32 { name: "Features", href: "#" }, 33 33 { name: "Calendar", href: "/calendar" }, 34 - { name: "Lists", href: "/lists" }, 35 34 { name: "Import", href: "/import" }, 36 35 ], 37 36 company: [
+12 -3
apps/web/src/components/Header.tsx
··· 2 2 import { 3 3 Calendar, 4 4 Film, 5 - List, 6 5 LogOut, 7 6 Menu, 7 + Settings, 8 8 User, 9 9 Users, 10 10 X, ··· 25 25 { name: "Dashboard", href: "/dashboard", icon: Film }, 26 26 { name: "Calendar", href: "/calendar", icon: Calendar }, 27 27 { name: "Following", href: "/following", icon: Users }, 28 - { name: "Lists", href: "/lists", icon: List }, 29 28 ]; 30 29 31 30 export default function Header() { ··· 165 164 </div> 166 165 <DropdownMenuSeparator /> 167 166 <DropdownMenuItem asChild> 168 - <Link to={"/dashboard" as const} className="cursor-pointer"> 167 + <Link 168 + to="/profile/$handle" 169 + params={{ handle: user.handle }} 170 + className="cursor-pointer" 171 + > 169 172 <User className="mr-2 h-4 w-4" /> 170 173 Profile 174 + </Link> 175 + </DropdownMenuItem> 176 + <DropdownMenuItem asChild> 177 + <Link to="/settings" className="cursor-pointer"> 178 + <Settings className="mr-2 h-4 w-4" /> 179 + Settings 171 180 </Link> 172 181 </DropdownMenuItem> 173 182 <DropdownMenuSeparator />
+23 -11
apps/web/src/components/InYourLists.tsx
··· 2 2 import { ChevronRight, Plus, X } from "lucide-react"; 3 3 import { useState } from "react"; 4 4 import ManageListsDialog from "#/components/ManageListsDialog"; 5 + import { useAuth } from "#/lib/auth-context"; 5 6 import { useListActions, useListItemStatus } from "#/lib/hooks"; 6 7 7 8 interface InYourListsProps { ··· 18 19 episodeNumber, 19 20 }: InYourListsProps) { 20 21 const [open, setOpen] = useState(false); 22 + const { user } = useAuth(); 23 + const userHandle = user?.handle; 21 24 const { otherLists, availableLists } = useListItemStatus({ 22 25 mediaType, 23 26 mediaId, ··· 42 45 className="group flex items-center rounded-lg transition-colors hover:bg-(--background-subtle)" 43 46 > 44 47 <Link 45 - to="/lists/$listSlug" 46 - params={{ listSlug: list.listSlug }} 48 + to="/profile/$handle/lists/$listSlug" 49 + params={{ 50 + handle: userHandle || "", 51 + listSlug: list.listSlug, 52 + }} 47 53 className="flex flex-1 items-center p-2" 48 54 > 49 55 <span className="font-medium text-sm">{list.listName}</span> ··· 58 64 <X className="h-4 w-4" /> 59 65 </button> 60 66 <Link 61 - to="/lists/$listSlug" 62 - params={{ listSlug: list.listSlug }} 67 + to="/profile/$handle/lists/$listSlug" 68 + params={{ 69 + handle: userHandle || "", 70 + listSlug: list.listSlug, 71 + }} 63 72 className="flex items-center p-2" 64 73 > 65 74 <ChevronRight className="h-4 w-4 text-(--foreground-muted)" /> ··· 71 80 <p className="text-(--foreground-muted) text-sm"> 72 81 Not in any lists yet 73 82 </p> 74 - <Link 75 - to="/lists" 76 - className="btn btn-secondary w-full gap-2 text-sm" 77 - > 78 - <Plus className="h-4 w-4" /> 79 - Create your first list 80 - </Link> 83 + {userHandle && ( 84 + <Link 85 + to="/profile/$handle/lists" 86 + params={{ handle: userHandle }} 87 + className="btn btn-secondary w-full gap-2 text-sm" 88 + > 89 + <Plus className="h-4 w-4" /> 90 + Create your first list 91 + </Link> 92 + )} 81 93 </div> 82 94 ) : ( 83 95 <p className="text-(--foreground-muted) text-sm">
+79
apps/web/src/components/Pagination.tsx
··· 1 + interface PaginationProps { 2 + page: number; 3 + totalPages: number; 4 + onPageChange: (page: number) => void; 5 + } 6 + 7 + export function Pagination({ 8 + page, 9 + totalPages, 10 + onPageChange, 11 + }: PaginationProps) { 12 + if (totalPages <= 1) return null; 13 + 14 + const getPageNumbers = () => { 15 + const pages: (number | string)[] = []; 16 + const maxVisible = 5; 17 + 18 + if (totalPages <= maxVisible + 2) { 19 + for (let i = 1; i <= totalPages; i++) pages.push(i); 20 + } else { 21 + pages.push(1); 22 + if (page > 3) pages.push("..."); 23 + 24 + const start = Math.max(2, page - 1); 25 + const end = Math.min(totalPages - 1, page + 1); 26 + for (let i = start; i <= end; i++) pages.push(i); 27 + 28 + if (page < totalPages - 2) pages.push("..."); 29 + pages.push(totalPages); 30 + } 31 + return pages; 32 + }; 33 + 34 + return ( 35 + <div className="flex items-center justify-center gap-1"> 36 + <button 37 + type="button" 38 + onClick={() => onPageChange(page - 1)} 39 + disabled={page <= 1} 40 + className="flex h-9 items-center rounded-md border border-(--border) bg-(--background-elevated) px-3 text-sm transition-colors hover:bg-(--background-subtle) disabled:opacity-40 disabled:hover:bg-(--background-elevated)" 41 + > 42 + ← Prev 43 + </button> 44 + 45 + {getPageNumbers().map((p, i) => 46 + p === "..." ? ( 47 + <span 48 + key={`ellipsis-${i}`} 49 + className="flex h-9 w-9 items-center justify-center text-(--foreground-muted) text-sm" 50 + > 51 + ... 52 + </span> 53 + ) : ( 54 + <button 55 + key={p} 56 + type="button" 57 + onClick={() => onPageChange(p as number)} 58 + className={`flex h-9 w-9 items-center justify-center rounded-md border font-medium text-sm transition-colors ${ 59 + page === p 60 + ? "border-(--accent) bg-(--accent) text-[#3f2e00]" 61 + : "border-(--border) bg-(--background-elevated) hover:bg-(--background-subtle)" 62 + }`} 63 + > 64 + {p} 65 + </button> 66 + ), 67 + )} 68 + 69 + <button 70 + type="button" 71 + onClick={() => onPageChange(page + 1)} 72 + disabled={page >= totalPages} 73 + className="flex h-9 items-center rounded-md border border-(--border) bg-(--background-elevated) px-3 text-sm transition-colors hover:bg-(--background-subtle) disabled:opacity-40 disabled:hover:bg-(--background-elevated)" 74 + > 75 + Next → 76 + </button> 77 + </div> 78 + ); 79 + }
+29 -14
apps/web/src/components/SearchCommand.tsx
··· 34 34 CommandSeparator, 35 35 CommandShortcut, 36 36 } from "#/components/ui/command"; 37 + import { useAuth } from "#/lib/auth-context"; 37 38 import { buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 38 39 39 40 interface SearchCommandProps { ··· 86 87 87 88 const isOpen = controlledOpen !== undefined ? controlledOpen : open; 88 89 const handleOpenChange = onOpenChange || setOpen; 90 + const { user } = useAuth(); 91 + const currentUserHandle = user?.handle; 89 92 90 93 // Search all API - only enabled when there's a search query 91 94 const { data: searchData, isLoading: isSearching } = useQuery({ ··· 204 207 <span>Following</span> 205 208 </Link> 206 209 </CommandItem> 207 - <CommandItem asChild> 208 - <Link to="/lists" className="flex items-center gap-2"> 209 - <List className="h-4 w-4" /> 210 - <span>Lists</span> 211 - </Link> 212 - </CommandItem> 210 + {currentUserHandle && ( 211 + <CommandItem asChild> 212 + <Link 213 + to="/profile/$handle/lists" 214 + params={{ handle: currentUserHandle }} 215 + className="flex items-center gap-2" 216 + > 217 + <List className="h-4 w-4" /> 218 + <span>Lists</span> 219 + </Link> 220 + </CommandItem> 221 + )} 213 222 </CommandGroup> 214 223 215 224 {/* Movies Section */} ··· 275 284 )} 276 285 277 286 {/* Your Lists Section - Always shown when available */} 278 - {userLists && userLists.length > 0 && ( 287 + {userLists && userLists.length > 0 && currentUserHandle && ( 279 288 <> 280 289 <CommandSeparator /> 281 290 <CommandGroup heading="Your Lists"> 282 291 {userLists.slice(0, 5).map((list: ListSummaryDto) => ( 283 292 <CommandItem key={`list-${list.id}`} asChild> 284 293 <Link 285 - to="/lists/$listSlug" 286 - params={{ listSlug: list.slug }} 294 + to="/profile/$handle/lists/$listSlug" 295 + params={{ 296 + handle: currentUserHandle, 297 + listSlug: list.slug, 298 + }} 287 299 className="flex items-center gap-2" 288 300 > 289 301 <List className="h-4 w-4" /> ··· 306 318 .map((person: SocialUserCardDto) => ( 307 319 <CommandItem key={`person-${person.did}`} asChild> 308 320 <Link 309 - to={`/profile/${person.handle || person.did}` as any} 321 + to="/profile/$handle" 322 + params={{ handle: person.handle || person.did }} 310 323 className="flex items-center gap-2" 311 324 > 312 325 <User className="h-4 w-4" /> ··· 338 351 <Heart className="h-4 w-4" /> 339 352 <span>Favorites</span> 340 353 </CommandItem> 341 - <CommandItem> 342 - <Settings className="h-4 w-4" /> 343 - <span>Settings</span> 344 - <CommandShortcut>⌘S</CommandShortcut> 354 + <CommandItem asChild> 355 + <Link to="/settings" className="flex items-center gap-2"> 356 + <Settings className="h-4 w-4" /> 357 + <span>Settings</span> 358 + <CommandShortcut>⌘S</CommandShortcut> 359 + </Link> 345 360 </CommandItem> 346 361 </CommandGroup> 347 362 </CommandList>
+169
apps/web/src/components/TimezoneSelector.tsx
··· 1 + import { Check, ChevronsUpDown } from "lucide-react"; 2 + import { useMemo, useState } from "react"; 3 + import { 4 + Command, 5 + CommandEmpty, 6 + CommandGroup, 7 + CommandInput, 8 + CommandItem, 9 + CommandList, 10 + } from "#/components/ui/command"; 11 + import { 12 + Popover, 13 + PopoverContent, 14 + PopoverTrigger, 15 + } from "#/components/ui/popover"; 16 + import { cn } from "#/lib/utils"; 17 + 18 + function getTimeZones(): string[] { 19 + try { 20 + return Intl.supportedValuesOf("timeZone"); 21 + } catch { 22 + return [ 23 + "UTC", 24 + "America/New_York", 25 + "America/Chicago", 26 + "America/Denver", 27 + "America/Los_Angeles", 28 + "America/Anchorage", 29 + "America/Honolulu", 30 + "Europe/London", 31 + "Europe/Paris", 32 + "Europe/Berlin", 33 + "Europe/Moscow", 34 + "Asia/Tokyo", 35 + "Asia/Shanghai", 36 + "Asia/Dubai", 37 + "Asia/Kolkata", 38 + "Asia/Singapore", 39 + "Australia/Sydney", 40 + "Pacific/Auckland", 41 + "Pacific/Honolulu", 42 + ]; 43 + } 44 + } 45 + 46 + function getTimezoneOffsetLabel(tz: string): string { 47 + try { 48 + const now = new Date(); 49 + const formatter = new Intl.DateTimeFormat("en-US", { 50 + timeZone: tz, 51 + timeZoneName: "shortOffset", 52 + }); 53 + const parts = formatter.formatToParts(now); 54 + const offsetPart = parts.find((p) => p.type === "timeZoneName"); 55 + return offsetPart?.value ?? ""; 56 + } catch { 57 + return ""; 58 + } 59 + } 60 + 61 + function getTimezoneOffsetMinutes(tz: string): number { 62 + try { 63 + const now = new Date(); 64 + const utcDate = new Date(now.toLocaleString("en-US", { timeZone: "UTC" })); 65 + const tzDate = new Date(now.toLocaleString("en-US", { timeZone: tz })); 66 + return (tzDate.getTime() - utcDate.getTime()) / 60000; 67 + } catch { 68 + return 0; 69 + } 70 + } 71 + 72 + interface TimezoneGroup { 73 + label: string; 74 + zones: { value: string; label: string }[]; 75 + } 76 + 77 + function buildTimezoneGroups(): TimezoneGroup[] { 78 + const zones = getTimeZones(); 79 + const map = new Map<number, { value: string; label: string }[]>(); 80 + 81 + for (const zone of zones) { 82 + const offsetMins = getTimezoneOffsetMinutes(zone); 83 + 84 + if (!map.has(offsetMins)) { 85 + map.set(offsetMins, []); 86 + } 87 + map.get(offsetMins)?.push({ value: zone, label: zone }); 88 + } 89 + 90 + const sortedEntries = Array.from(map.entries()).sort((a, b) => b[0] - a[0]); 91 + 92 + return sortedEntries.map(([offsetMins, zoneList]) => { 93 + const sampleZone = zoneList[0].value; 94 + const offsetLabel = getTimezoneOffsetLabel(sampleZone); 95 + const hours = Math.floor(Math.abs(offsetMins) / 60); 96 + const mins = Math.abs(offsetMins) % 60; 97 + const sign = offsetMins >= 0 ? "+" : "-"; 98 + const numeric = `UTC${sign}${hours}${mins > 0 ? `:${mins.toString().padStart(2, "0")}` : ""}`; 99 + return { 100 + label: `${offsetLabel || numeric} — ${numeric}`, 101 + zones: zoneList.sort((a, b) => a.label.localeCompare(b.label)), 102 + }; 103 + }); 104 + } 105 + 106 + interface TimezoneSelectorProps { 107 + value?: string; 108 + onChange: (value: string) => void; 109 + disabled?: boolean; 110 + } 111 + 112 + export default function TimezoneSelector({ 113 + value, 114 + onChange, 115 + disabled, 116 + }: TimezoneSelectorProps) { 117 + const [open, setOpen] = useState(false); 118 + const groups = useMemo(() => buildTimezoneGroups(), []); 119 + 120 + return ( 121 + <Popover open={open} onOpenChange={setOpen}> 122 + <PopoverTrigger asChild> 123 + <button 124 + type="button" 125 + disabled={disabled} 126 + aria-expanded={open} 127 + aria-label="Select timezone" 128 + className={cn( 129 + "input flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-left font-normal text-sm shadow-none transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50", 130 + !value && "text-muted-foreground", 131 + )} 132 + > 133 + <span className="truncate">{value ?? "Select timezone"}</span> 134 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 135 + </button> 136 + </PopoverTrigger> 137 + <PopoverContent className="w-[320px] p-0" align="start"> 138 + <Command> 139 + <CommandInput placeholder="Search timezone…" /> 140 + <CommandList> 141 + <CommandEmpty>No timezone found.</CommandEmpty> 142 + {groups.map((group) => ( 143 + <CommandGroup key={group.label} heading={group.label}> 144 + {group.zones.map((zone) => ( 145 + <CommandItem 146 + key={zone.value} 147 + value={zone.value} 148 + onSelect={() => { 149 + onChange(zone.value); 150 + setOpen(false); 151 + }} 152 + > 153 + <span className="truncate">{zone.label}</span> 154 + <Check 155 + className={cn( 156 + "ml-auto h-4 w-4", 157 + value === zone.value ? "opacity-100" : "opacity-0", 158 + )} 159 + /> 160 + </CommandItem> 161 + ))} 162 + </CommandGroup> 163 + ))} 164 + </CommandList> 165 + </Command> 166 + </PopoverContent> 167 + </Popover> 168 + ); 169 + }
+105
apps/web/src/components/YourActivity.tsx
··· 1 + import { Loader2, Plus, X } from "lucide-react"; 2 + import { useAuth } from "#/lib/auth-context"; 3 + import { formatDateTime } from "#/lib/date-utils"; 4 + 5 + interface WatchHistoryEntry { 6 + id: string; 7 + watchedDate?: string; 8 + } 9 + 10 + interface YourActivityProps { 11 + watchHistory: WatchHistoryEntry[]; 12 + onAddToShelf: () => void; 13 + onDeleteEntry: (id: string) => void; 14 + isAddPending?: boolean; 15 + isDeletePending?: boolean; 16 + } 17 + 18 + export function YourActivity({ 19 + watchHistory, 20 + onAddToShelf, 21 + onDeleteEntry, 22 + isAddPending = false, 23 + isDeletePending = false, 24 + }: YourActivityProps) { 25 + const { userSettings } = useAuth(); 26 + const userTimezone = userSettings?.timezone; 27 + const userTimeFormat = userSettings?.timeFormat; 28 + 29 + return ( 30 + <section className="card p-5"> 31 + <h3 className="mb-4 font-display font-semibold">Your Activity</h3> 32 + {watchHistory.length > 0 ? ( 33 + <div className="space-y-1"> 34 + {watchHistory.map((entry, index) => ( 35 + <div 36 + key={entry.id || index} 37 + className="group flex items-center rounded-lg transition-colors hover:bg-(--background-subtle)" 38 + > 39 + <div className="flex flex-1 items-center p-2"> 40 + <span className="font-medium text-sm"> 41 + {formatDateTime( 42 + entry.watchedDate || "", 43 + userTimezone, 44 + userTimeFormat, 45 + )} 46 + </span> 47 + </div> 48 + <button 49 + type="button" 50 + onClick={() => onDeleteEntry(entry.id)} 51 + disabled={isDeletePending} 52 + className="flex h-8 w-8 items-center justify-center rounded-md text-(--foreground-muted) transition-colors hover:bg-red-500/10 hover:text-red-500" 53 + aria-label="Remove this play" 54 + > 55 + <X className="h-4 w-4" /> 56 + </button> 57 + </div> 58 + ))} 59 + <button 60 + type="button" 61 + onClick={onAddToShelf} 62 + disabled={isAddPending} 63 + className="btn btn-secondary mt-3 w-full gap-2" 64 + > 65 + {isAddPending ? ( 66 + <> 67 + <Loader2 className="h-4 w-4 animate-spin" /> 68 + Loading 69 + </> 70 + ) : ( 71 + <> 72 + <Plus className="h-4 w-4" /> 73 + Add to shelf 74 + </> 75 + )} 76 + </button> 77 + </div> 78 + ) : ( 79 + <div className="space-y-3"> 80 + <p className="text-(--foreground-muted) text-sm"> 81 + You haven&apos;t watched this yet 82 + </p> 83 + <button 84 + type="button" 85 + onClick={onAddToShelf} 86 + disabled={isAddPending} 87 + className="btn btn-secondary w-full gap-2 text-sm" 88 + > 89 + {isAddPending ? ( 90 + <> 91 + <Loader2 className="h-4 w-4 animate-spin" /> 92 + Loading 93 + </> 94 + ) : ( 95 + <> 96 + <Plus className="h-4 w-4" /> 97 + Add to shelf 98 + </> 99 + )} 100 + </button> 101 + </div> 102 + )} 103 + </section> 104 + ); 105 + }
+87
apps/web/src/components/ui/popover.tsx
··· 1 + import { Popover as PopoverPrimitive } from "radix-ui"; 2 + import type * as React from "react"; 3 + 4 + import { cn } from "#/lib/utils"; 5 + 6 + function Popover({ 7 + ...props 8 + }: React.ComponentProps<typeof PopoverPrimitive.Root>) { 9 + return <PopoverPrimitive.Root data-slot="popover" {...props} />; 10 + } 11 + 12 + function PopoverTrigger({ 13 + ...props 14 + }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { 15 + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; 16 + } 17 + 18 + function PopoverContent({ 19 + className, 20 + align = "center", 21 + sideOffset = 4, 22 + ...props 23 + }: React.ComponentProps<typeof PopoverPrimitive.Content>) { 24 + return ( 25 + <PopoverPrimitive.Portal> 26 + <PopoverPrimitive.Content 27 + data-slot="popover-content" 28 + align={align} 29 + sideOffset={sideOffset} 30 + className={cn( 31 + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in", 32 + className, 33 + )} 34 + {...props} 35 + /> 36 + </PopoverPrimitive.Portal> 37 + ); 38 + } 39 + 40 + function PopoverAnchor({ 41 + ...props 42 + }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { 43 + return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; 44 + } 45 + 46 + function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { 47 + return ( 48 + <div 49 + data-slot="popover-header" 50 + className={cn("flex flex-col gap-1 text-sm", className)} 51 + {...props} 52 + /> 53 + ); 54 + } 55 + 56 + function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { 57 + return ( 58 + <div 59 + data-slot="popover-title" 60 + className={cn("font-medium", className)} 61 + {...props} 62 + /> 63 + ); 64 + } 65 + 66 + function PopoverDescription({ 67 + className, 68 + ...props 69 + }: React.ComponentProps<"p">) { 70 + return ( 71 + <p 72 + data-slot="popover-description" 73 + className={cn("text-muted-foreground", className)} 74 + {...props} 75 + /> 76 + ); 77 + } 78 + 79 + export { 80 + Popover, 81 + PopoverTrigger, 82 + PopoverContent, 83 + PopoverAnchor, 84 + PopoverHeader, 85 + PopoverTitle, 86 + PopoverDescription, 87 + };
+33
apps/web/src/components/ui/switch.tsx
··· 1 + import { Switch as SwitchPrimitive } from "radix-ui"; 2 + import type * as React from "react"; 3 + 4 + import { cn } from "#/lib/utils"; 5 + 6 + function Switch({ 7 + className, 8 + size = "default", 9 + ...props 10 + }: React.ComponentProps<typeof SwitchPrimitive.Root> & { 11 + size?: "sm" | "default"; 12 + }) { 13 + return ( 14 + <SwitchPrimitive.Root 15 + data-slot="switch" 16 + data-size={size} 17 + className={cn( 18 + "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=sm]:h-3.5 data-[size=default]:w-8 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80", 19 + className, 20 + )} 21 + {...props} 22 + > 23 + <SwitchPrimitive.Thumb 24 + data-slot="switch-thumb" 25 + className={cn( 26 + "pointer-events-none block rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground", 27 + )} 28 + /> 29 + </SwitchPrimitive.Root> 30 + ); 31 + } 32 + 33 + export { Switch };
+31
apps/web/src/lib/date-utils.ts
··· 22 22 } 23 23 24 24 /** 25 + * Format a date-time string into a human-readable date and time. 26 + * Respects user timezone and 12/24-hour format preferences. 27 + * Falls back to the raw string if parsing fails. 28 + */ 29 + export function formatDateTime( 30 + dateString: string, 31 + timezone?: string, 32 + timeFormat?: "12h" | "24h", 33 + ): string { 34 + if (!dateString) return "Unknown"; 35 + try { 36 + return new Date(dateString).toLocaleString( 37 + "en-US", 38 + withUserLocale( 39 + { 40 + month: "short", 41 + day: "numeric", 42 + year: "numeric", 43 + hour: "numeric", 44 + minute: "2-digit", 45 + }, 46 + timezone, 47 + timeFormat, 48 + ), 49 + ); 50 + } catch { 51 + return dateString; 52 + } 53 + } 54 + 55 + /** 25 56 * Format a date string into a human-readable date. 26 57 * Falls back to the raw string if parsing fails. 27 58 */
+47
apps/web/src/lib/hooks/usePublicProfile.ts
··· 1 + import type { PaginatedSocialUsersDto } from "@opnshelf/api"; 2 + import { client } from "@opnshelf/api"; 3 + import { useQuery } from "@tanstack/react-query"; 4 + 5 + // Custom API functions for public profile endpoints not yet in the generated SDK 6 + 7 + export async function getPublicFollowers( 8 + handle: string, 9 + page = 1, 10 + pageSize = 20, 11 + ): Promise<PaginatedSocialUsersDto> { 12 + const { data } = await client.get({ 13 + url: `/users/${handle}/followers`, 14 + query: { page, pageSize }, 15 + }); 16 + return data as PaginatedSocialUsersDto; 17 + } 18 + 19 + export async function getPublicFollowing( 20 + handle: string, 21 + page = 1, 22 + pageSize = 20, 23 + ): Promise<PaginatedSocialUsersDto> { 24 + const { data } = await client.get({ 25 + url: `/users/${handle}/following`, 26 + query: { page, pageSize }, 27 + }); 28 + return data as PaginatedSocialUsersDto; 29 + } 30 + 31 + // TanStack Query hooks 32 + 33 + export function usePublicFollowers(handle: string, page = 1, pageSize = 20) { 34 + return useQuery({ 35 + queryKey: ["public-profile", "followers", handle, page, pageSize], 36 + queryFn: () => getPublicFollowers(handle, page, pageSize), 37 + enabled: !!handle, 38 + }); 39 + } 40 + 41 + export function usePublicFollowing(handle: string, page = 1, pageSize = 20) { 42 + return useQuery({ 43 + queryKey: ["public-profile", "following", handle, page, pageSize], 44 + queryFn: () => getPublicFollowing(handle, page, pageSize), 45 + enabled: !!handle, 46 + }); 47 + }
+199 -62
apps/web/src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 + import { Route as SettingsRouteImport } from './routes/settings' 12 13 import { Route as LoginRouteImport } from './routes/login' 13 - import { Route as ListsRouteImport } from './routes/lists' 14 14 import { Route as FollowingRouteImport } from './routes/following' 15 15 import { Route as DashboardRouteImport } from './routes/dashboard' 16 16 import { Route as CalendarRouteImport } from './routes/calendar' 17 17 import { Route as AboutRouteImport } from './routes/about' 18 18 import { Route as IndexRouteImport } from './routes/index' 19 - import { Route as ListsIndexRouteImport } from './routes/lists/index' 20 - import { Route as ListsListSlugRouteImport } from './routes/lists.$listSlug' 19 + import { Route as ProfileHandleRouteImport } from './routes/profile.$handle' 21 20 import { Route as AuthCompleteRouteImport } from './routes/auth/complete' 21 + import { Route as ProfileHandleIndexRouteImport } from './routes/profile.$handle/index' 22 22 import { Route as ShowsShowIdShowNameRouteImport } from './routes/shows/$showId/$showName' 23 + import { Route as ProfileHandleUpNextRouteImport } from './routes/profile.$handle/up-next' 24 + import { Route as ProfileHandleShelfRouteImport } from './routes/profile.$handle/shelf' 25 + import { Route as ProfileHandleListsRouteImport } from './routes/profile.$handle/lists' 26 + import { Route as ProfileHandleConnectionsRouteImport } from './routes/profile.$handle/connections' 23 27 import { Route as MoviesMovieIdMovieNameRouteImport } from './routes/movies/$movieId/$movieName' 24 28 import { Route as ShowsShowIdShowNameIndexRouteImport } from './routes/shows/$showId/$showName/index' 29 + import { Route as ProfileHandleListsIndexRouteImport } from './routes/profile.$handle/lists.index' 30 + import { Route as ProfileHandleListsListSlugRouteImport } from './routes/profile.$handle/lists.$listSlug' 25 31 import { Route as ShowsShowIdShowNameSeasonsSeasonNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber' 26 32 import { Route as ShowsShowIdShowNameSeasonsSeasonNumberIndexRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber/index' 27 33 import { Route as ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRouteImport } from './routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber' 28 34 35 + const SettingsRoute = SettingsRouteImport.update({ 36 + id: '/settings', 37 + path: '/settings', 38 + getParentRoute: () => rootRouteImport, 39 + } as any) 29 40 const LoginRoute = LoginRouteImport.update({ 30 41 id: '/login', 31 42 path: '/login', 32 43 getParentRoute: () => rootRouteImport, 33 44 } as any) 34 - const ListsRoute = ListsRouteImport.update({ 35 - id: '/lists', 36 - path: '/lists', 37 - getParentRoute: () => rootRouteImport, 38 - } as any) 39 45 const FollowingRoute = FollowingRouteImport.update({ 40 46 id: '/following', 41 47 path: '/following', ··· 61 67 path: '/', 62 68 getParentRoute: () => rootRouteImport, 63 69 } as any) 64 - const ListsIndexRoute = ListsIndexRouteImport.update({ 65 - id: '/', 66 - path: '/', 67 - getParentRoute: () => ListsRoute, 68 - } as any) 69 - const ListsListSlugRoute = ListsListSlugRouteImport.update({ 70 - id: '/$listSlug', 71 - path: '/$listSlug', 72 - getParentRoute: () => ListsRoute, 70 + const ProfileHandleRoute = ProfileHandleRouteImport.update({ 71 + id: '/profile/$handle', 72 + path: '/profile/$handle', 73 + getParentRoute: () => rootRouteImport, 73 74 } as any) 74 75 const AuthCompleteRoute = AuthCompleteRouteImport.update({ 75 76 id: '/auth/complete', 76 77 path: '/auth/complete', 77 78 getParentRoute: () => rootRouteImport, 78 79 } as any) 80 + const ProfileHandleIndexRoute = ProfileHandleIndexRouteImport.update({ 81 + id: '/', 82 + path: '/', 83 + getParentRoute: () => ProfileHandleRoute, 84 + } as any) 79 85 const ShowsShowIdShowNameRoute = ShowsShowIdShowNameRouteImport.update({ 80 86 id: '/shows/$showId/$showName', 81 87 path: '/shows/$showId/$showName', 82 88 getParentRoute: () => rootRouteImport, 83 89 } as any) 90 + const ProfileHandleUpNextRoute = ProfileHandleUpNextRouteImport.update({ 91 + id: '/up-next', 92 + path: '/up-next', 93 + getParentRoute: () => ProfileHandleRoute, 94 + } as any) 95 + const ProfileHandleShelfRoute = ProfileHandleShelfRouteImport.update({ 96 + id: '/shelf', 97 + path: '/shelf', 98 + getParentRoute: () => ProfileHandleRoute, 99 + } as any) 100 + const ProfileHandleListsRoute = ProfileHandleListsRouteImport.update({ 101 + id: '/lists', 102 + path: '/lists', 103 + getParentRoute: () => ProfileHandleRoute, 104 + } as any) 105 + const ProfileHandleConnectionsRoute = 106 + ProfileHandleConnectionsRouteImport.update({ 107 + id: '/connections', 108 + path: '/connections', 109 + getParentRoute: () => ProfileHandleRoute, 110 + } as any) 84 111 const MoviesMovieIdMovieNameRoute = MoviesMovieIdMovieNameRouteImport.update({ 85 112 id: '/movies/$movieId/$movieName', 86 113 path: '/movies/$movieId/$movieName', ··· 92 119 path: '/', 93 120 getParentRoute: () => ShowsShowIdShowNameRoute, 94 121 } as any) 122 + const ProfileHandleListsIndexRoute = ProfileHandleListsIndexRouteImport.update({ 123 + id: '/', 124 + path: '/', 125 + getParentRoute: () => ProfileHandleListsRoute, 126 + } as any) 127 + const ProfileHandleListsListSlugRoute = 128 + ProfileHandleListsListSlugRouteImport.update({ 129 + id: '/$listSlug', 130 + path: '/$listSlug', 131 + getParentRoute: () => ProfileHandleListsRoute, 132 + } as any) 95 133 const ShowsShowIdShowNameSeasonsSeasonNumberRoute = 96 134 ShowsShowIdShowNameSeasonsSeasonNumberRouteImport.update({ 97 135 id: '/seasons/$seasonNumber', ··· 119 157 '/calendar': typeof CalendarRoute 120 158 '/dashboard': typeof DashboardRoute 121 159 '/following': typeof FollowingRoute 122 - '/lists': typeof ListsRouteWithChildren 123 160 '/login': typeof LoginRoute 161 + '/settings': typeof SettingsRoute 124 162 '/auth/complete': typeof AuthCompleteRoute 125 - '/lists/$listSlug': typeof ListsListSlugRoute 126 - '/lists/': typeof ListsIndexRoute 163 + '/profile/$handle': typeof ProfileHandleRouteWithChildren 127 164 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 165 + '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 166 + '/profile/$handle/lists': typeof ProfileHandleListsRouteWithChildren 167 + '/profile/$handle/shelf': typeof ProfileHandleShelfRoute 168 + '/profile/$handle/up-next': typeof ProfileHandleUpNextRoute 128 169 '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 170 + '/profile/$handle/': typeof ProfileHandleIndexRoute 171 + '/profile/$handle/lists/$listSlug': typeof ProfileHandleListsListSlugRoute 172 + '/profile/$handle/lists/': typeof ProfileHandleListsIndexRoute 129 173 '/shows/$showId/$showName/': typeof ShowsShowIdShowNameIndexRoute 130 174 '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 131 175 '/shows/$showId/$showName/seasons/$seasonNumber/': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute ··· 138 182 '/dashboard': typeof DashboardRoute 139 183 '/following': typeof FollowingRoute 140 184 '/login': typeof LoginRoute 185 + '/settings': typeof SettingsRoute 141 186 '/auth/complete': typeof AuthCompleteRoute 142 - '/lists/$listSlug': typeof ListsListSlugRoute 143 - '/lists': typeof ListsIndexRoute 144 187 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 188 + '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 189 + '/profile/$handle/shelf': typeof ProfileHandleShelfRoute 190 + '/profile/$handle/up-next': typeof ProfileHandleUpNextRoute 191 + '/profile/$handle': typeof ProfileHandleIndexRoute 192 + '/profile/$handle/lists/$listSlug': typeof ProfileHandleListsListSlugRoute 193 + '/profile/$handle/lists': typeof ProfileHandleListsIndexRoute 145 194 '/shows/$showId/$showName': typeof ShowsShowIdShowNameIndexRoute 146 195 '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute 147 196 '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberEpisodesEpisodeNumberRoute ··· 153 202 '/calendar': typeof CalendarRoute 154 203 '/dashboard': typeof DashboardRoute 155 204 '/following': typeof FollowingRoute 156 - '/lists': typeof ListsRouteWithChildren 157 205 '/login': typeof LoginRoute 206 + '/settings': typeof SettingsRoute 158 207 '/auth/complete': typeof AuthCompleteRoute 159 - '/lists/$listSlug': typeof ListsListSlugRoute 160 - '/lists/': typeof ListsIndexRoute 208 + '/profile/$handle': typeof ProfileHandleRouteWithChildren 161 209 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 210 + '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 211 + '/profile/$handle/lists': typeof ProfileHandleListsRouteWithChildren 212 + '/profile/$handle/shelf': typeof ProfileHandleShelfRoute 213 + '/profile/$handle/up-next': typeof ProfileHandleUpNextRoute 162 214 '/shows/$showId/$showName': typeof ShowsShowIdShowNameRouteWithChildren 215 + '/profile/$handle/': typeof ProfileHandleIndexRoute 216 + '/profile/$handle/lists/$listSlug': typeof ProfileHandleListsListSlugRoute 217 + '/profile/$handle/lists/': typeof ProfileHandleListsIndexRoute 163 218 '/shows/$showId/$showName/': typeof ShowsShowIdShowNameIndexRoute 164 219 '/shows/$showId/$showName/seasons/$seasonNumber': typeof ShowsShowIdShowNameSeasonsSeasonNumberRouteWithChildren 165 220 '/shows/$showId/$showName/seasons/$seasonNumber/': typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute ··· 173 228 | '/calendar' 174 229 | '/dashboard' 175 230 | '/following' 176 - | '/lists' 177 231 | '/login' 232 + | '/settings' 178 233 | '/auth/complete' 179 - | '/lists/$listSlug' 180 - | '/lists/' 234 + | '/profile/$handle' 181 235 | '/movies/$movieId/$movieName' 236 + | '/profile/$handle/connections' 237 + | '/profile/$handle/lists' 238 + | '/profile/$handle/shelf' 239 + | '/profile/$handle/up-next' 182 240 | '/shows/$showId/$showName' 241 + | '/profile/$handle/' 242 + | '/profile/$handle/lists/$listSlug' 243 + | '/profile/$handle/lists/' 183 244 | '/shows/$showId/$showName/' 184 245 | '/shows/$showId/$showName/seasons/$seasonNumber' 185 246 | '/shows/$showId/$showName/seasons/$seasonNumber/' ··· 192 253 | '/dashboard' 193 254 | '/following' 194 255 | '/login' 256 + | '/settings' 195 257 | '/auth/complete' 196 - | '/lists/$listSlug' 197 - | '/lists' 198 258 | '/movies/$movieId/$movieName' 259 + | '/profile/$handle/connections' 260 + | '/profile/$handle/shelf' 261 + | '/profile/$handle/up-next' 262 + | '/profile/$handle' 263 + | '/profile/$handle/lists/$listSlug' 264 + | '/profile/$handle/lists' 199 265 | '/shows/$showId/$showName' 200 266 | '/shows/$showId/$showName/seasons/$seasonNumber' 201 267 | '/shows/$showId/$showName/seasons/$seasonNumber/episodes/$episodeNumber' ··· 206 272 | '/calendar' 207 273 | '/dashboard' 208 274 | '/following' 209 - | '/lists' 210 275 | '/login' 276 + | '/settings' 211 277 | '/auth/complete' 212 - | '/lists/$listSlug' 213 - | '/lists/' 278 + | '/profile/$handle' 214 279 | '/movies/$movieId/$movieName' 280 + | '/profile/$handle/connections' 281 + | '/profile/$handle/lists' 282 + | '/profile/$handle/shelf' 283 + | '/profile/$handle/up-next' 215 284 | '/shows/$showId/$showName' 285 + | '/profile/$handle/' 286 + | '/profile/$handle/lists/$listSlug' 287 + | '/profile/$handle/lists/' 216 288 | '/shows/$showId/$showName/' 217 289 | '/shows/$showId/$showName/seasons/$seasonNumber' 218 290 | '/shows/$showId/$showName/seasons/$seasonNumber/' ··· 225 297 CalendarRoute: typeof CalendarRoute 226 298 DashboardRoute: typeof DashboardRoute 227 299 FollowingRoute: typeof FollowingRoute 228 - ListsRoute: typeof ListsRouteWithChildren 229 300 LoginRoute: typeof LoginRoute 301 + SettingsRoute: typeof SettingsRoute 230 302 AuthCompleteRoute: typeof AuthCompleteRoute 303 + ProfileHandleRoute: typeof ProfileHandleRouteWithChildren 231 304 MoviesMovieIdMovieNameRoute: typeof MoviesMovieIdMovieNameRoute 232 305 ShowsShowIdShowNameRoute: typeof ShowsShowIdShowNameRouteWithChildren 233 306 } 234 307 235 308 declare module '@tanstack/react-router' { 236 309 interface FileRoutesByPath { 310 + '/settings': { 311 + id: '/settings' 312 + path: '/settings' 313 + fullPath: '/settings' 314 + preLoaderRoute: typeof SettingsRouteImport 315 + parentRoute: typeof rootRouteImport 316 + } 237 317 '/login': { 238 318 id: '/login' 239 319 path: '/login' ··· 241 321 preLoaderRoute: typeof LoginRouteImport 242 322 parentRoute: typeof rootRouteImport 243 323 } 244 - '/lists': { 245 - id: '/lists' 246 - path: '/lists' 247 - fullPath: '/lists' 248 - preLoaderRoute: typeof ListsRouteImport 249 - parentRoute: typeof rootRouteImport 250 - } 251 324 '/following': { 252 325 id: '/following' 253 326 path: '/following' ··· 283 356 preLoaderRoute: typeof IndexRouteImport 284 357 parentRoute: typeof rootRouteImport 285 358 } 286 - '/lists/': { 287 - id: '/lists/' 288 - path: '/' 289 - fullPath: '/lists/' 290 - preLoaderRoute: typeof ListsIndexRouteImport 291 - parentRoute: typeof ListsRoute 292 - } 293 - '/lists/$listSlug': { 294 - id: '/lists/$listSlug' 295 - path: '/$listSlug' 296 - fullPath: '/lists/$listSlug' 297 - preLoaderRoute: typeof ListsListSlugRouteImport 298 - parentRoute: typeof ListsRoute 359 + '/profile/$handle': { 360 + id: '/profile/$handle' 361 + path: '/profile/$handle' 362 + fullPath: '/profile/$handle' 363 + preLoaderRoute: typeof ProfileHandleRouteImport 364 + parentRoute: typeof rootRouteImport 299 365 } 300 366 '/auth/complete': { 301 367 id: '/auth/complete' ··· 304 370 preLoaderRoute: typeof AuthCompleteRouteImport 305 371 parentRoute: typeof rootRouteImport 306 372 } 373 + '/profile/$handle/': { 374 + id: '/profile/$handle/' 375 + path: '/' 376 + fullPath: '/profile/$handle/' 377 + preLoaderRoute: typeof ProfileHandleIndexRouteImport 378 + parentRoute: typeof ProfileHandleRoute 379 + } 307 380 '/shows/$showId/$showName': { 308 381 id: '/shows/$showId/$showName' 309 382 path: '/shows/$showId/$showName' ··· 311 384 preLoaderRoute: typeof ShowsShowIdShowNameRouteImport 312 385 parentRoute: typeof rootRouteImport 313 386 } 387 + '/profile/$handle/up-next': { 388 + id: '/profile/$handle/up-next' 389 + path: '/up-next' 390 + fullPath: '/profile/$handle/up-next' 391 + preLoaderRoute: typeof ProfileHandleUpNextRouteImport 392 + parentRoute: typeof ProfileHandleRoute 393 + } 394 + '/profile/$handle/shelf': { 395 + id: '/profile/$handle/shelf' 396 + path: '/shelf' 397 + fullPath: '/profile/$handle/shelf' 398 + preLoaderRoute: typeof ProfileHandleShelfRouteImport 399 + parentRoute: typeof ProfileHandleRoute 400 + } 401 + '/profile/$handle/lists': { 402 + id: '/profile/$handle/lists' 403 + path: '/lists' 404 + fullPath: '/profile/$handle/lists' 405 + preLoaderRoute: typeof ProfileHandleListsRouteImport 406 + parentRoute: typeof ProfileHandleRoute 407 + } 408 + '/profile/$handle/connections': { 409 + id: '/profile/$handle/connections' 410 + path: '/connections' 411 + fullPath: '/profile/$handle/connections' 412 + preLoaderRoute: typeof ProfileHandleConnectionsRouteImport 413 + parentRoute: typeof ProfileHandleRoute 414 + } 314 415 '/movies/$movieId/$movieName': { 315 416 id: '/movies/$movieId/$movieName' 316 417 path: '/movies/$movieId/$movieName' ··· 325 426 preLoaderRoute: typeof ShowsShowIdShowNameIndexRouteImport 326 427 parentRoute: typeof ShowsShowIdShowNameRoute 327 428 } 429 + '/profile/$handle/lists/': { 430 + id: '/profile/$handle/lists/' 431 + path: '/' 432 + fullPath: '/profile/$handle/lists/' 433 + preLoaderRoute: typeof ProfileHandleListsIndexRouteImport 434 + parentRoute: typeof ProfileHandleListsRoute 435 + } 436 + '/profile/$handle/lists/$listSlug': { 437 + id: '/profile/$handle/lists/$listSlug' 438 + path: '/$listSlug' 439 + fullPath: '/profile/$handle/lists/$listSlug' 440 + preLoaderRoute: typeof ProfileHandleListsListSlugRouteImport 441 + parentRoute: typeof ProfileHandleListsRoute 442 + } 328 443 '/shows/$showId/$showName/seasons/$seasonNumber': { 329 444 id: '/shows/$showId/$showName/seasons/$seasonNumber' 330 445 path: '/seasons/$seasonNumber' ··· 349 464 } 350 465 } 351 466 352 - interface ListsRouteChildren { 353 - ListsListSlugRoute: typeof ListsListSlugRoute 354 - ListsIndexRoute: typeof ListsIndexRoute 467 + interface ProfileHandleListsRouteChildren { 468 + ProfileHandleListsListSlugRoute: typeof ProfileHandleListsListSlugRoute 469 + ProfileHandleListsIndexRoute: typeof ProfileHandleListsIndexRoute 470 + } 471 + 472 + const ProfileHandleListsRouteChildren: ProfileHandleListsRouteChildren = { 473 + ProfileHandleListsListSlugRoute: ProfileHandleListsListSlugRoute, 474 + ProfileHandleListsIndexRoute: ProfileHandleListsIndexRoute, 475 + } 476 + 477 + const ProfileHandleListsRouteWithChildren = 478 + ProfileHandleListsRoute._addFileChildren(ProfileHandleListsRouteChildren) 479 + 480 + interface ProfileHandleRouteChildren { 481 + ProfileHandleConnectionsRoute: typeof ProfileHandleConnectionsRoute 482 + ProfileHandleListsRoute: typeof ProfileHandleListsRouteWithChildren 483 + ProfileHandleShelfRoute: typeof ProfileHandleShelfRoute 484 + ProfileHandleUpNextRoute: typeof ProfileHandleUpNextRoute 485 + ProfileHandleIndexRoute: typeof ProfileHandleIndexRoute 355 486 } 356 487 357 - const ListsRouteChildren: ListsRouteChildren = { 358 - ListsListSlugRoute: ListsListSlugRoute, 359 - ListsIndexRoute: ListsIndexRoute, 488 + const ProfileHandleRouteChildren: ProfileHandleRouteChildren = { 489 + ProfileHandleConnectionsRoute: ProfileHandleConnectionsRoute, 490 + ProfileHandleListsRoute: ProfileHandleListsRouteWithChildren, 491 + ProfileHandleShelfRoute: ProfileHandleShelfRoute, 492 + ProfileHandleUpNextRoute: ProfileHandleUpNextRoute, 493 + ProfileHandleIndexRoute: ProfileHandleIndexRoute, 360 494 } 361 495 362 - const ListsRouteWithChildren = ListsRoute._addFileChildren(ListsRouteChildren) 496 + const ProfileHandleRouteWithChildren = ProfileHandleRoute._addFileChildren( 497 + ProfileHandleRouteChildren, 498 + ) 363 499 364 500 interface ShowsShowIdShowNameSeasonsSeasonNumberRouteChildren { 365 501 ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute: typeof ShowsShowIdShowNameSeasonsSeasonNumberIndexRoute ··· 399 535 CalendarRoute: CalendarRoute, 400 536 DashboardRoute: DashboardRoute, 401 537 FollowingRoute: FollowingRoute, 402 - ListsRoute: ListsRouteWithChildren, 403 538 LoginRoute: LoginRoute, 539 + SettingsRoute: SettingsRoute, 404 540 AuthCompleteRoute: AuthCompleteRoute, 541 + ProfileHandleRoute: ProfileHandleRouteWithChildren, 405 542 MoviesMovieIdMovieNameRoute: MoviesMovieIdMovieNameRoute, 406 543 ShowsShowIdShowNameRoute: ShowsShowIdShowNameRouteWithChildren, 407 544 }
+20 -14
apps/web/src/routes/dashboard.tsx
··· 355 355 <section> 356 356 <div className="mb-4 flex items-center justify-between"> 357 357 <h2 className="text-display-3">Up Next</h2> 358 - <Link 359 - to={"/dashboard" as const} 360 - className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 361 - > 362 - View all 363 - <ChevronRight className="h-4 w-4" /> 364 - </Link> 358 + {user && ( 359 + <Link 360 + to="/profile/$handle/up-next" 361 + params={{ handle: user.handle }} 362 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 363 + > 364 + View all 365 + <ChevronRight className="h-4 w-4" /> 366 + </Link> 367 + )} 365 368 </div> 366 369 367 370 {upNextLoading ? ( ··· 421 424 <section> 422 425 <div className="mb-4 flex items-center justify-between"> 423 426 <h2 className="text-display-3">Your Shelf</h2> 424 - <Link 425 - to={"/dashboard" as const} 426 - className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 427 - > 428 - View all 429 - <ChevronRight className="h-4 w-4" /> 430 - </Link> 427 + {user && ( 428 + <Link 429 + to="/profile/$handle/shelf" 430 + params={{ handle: user.handle }} 431 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 432 + > 433 + View all 434 + <ChevronRight className="h-4 w-4" /> 435 + </Link> 436 + )} 431 437 </div> 432 438 433 439 {isLoading ? (
-46
apps/web/src/routes/lists.$listSlug.tsx
··· 1 - import { listsControllerGetListOptions } from "@opnshelf/api"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 - import { setupApiClient } from "#/lib/api"; 4 - import { buildListPageMeta, ListsPage } from "./lists"; 5 - 6 - setupApiClient(); 7 - 8 - export const Route = createFileRoute("/lists/$listSlug")({ 9 - loader: async ({ context, params }) => { 10 - return context.queryClient.ensureQueryData( 11 - listsControllerGetListOptions({ 12 - path: { slug: params.listSlug }, 13 - }), 14 - ); 15 - }, 16 - head: ({ loaderData, params }) => { 17 - const meta = buildListPageMeta( 18 - loaderData 19 - ? { 20 - name: loaderData.name, 21 - description: loaderData.description, 22 - total: loaderData.total, 23 - } 24 - : { 25 - name: params.listSlug, 26 - }, 27 - ); 28 - 29 - return { 30 - meta: [ 31 - { title: meta.title }, 32 - { 33 - name: "description", 34 - content: meta.description, 35 - }, 36 - ], 37 - }; 38 - }, 39 - component: ListDetailPage, 40 - }); 41 - 42 - function ListDetailPage() { 43 - const { listSlug } = Route.useParams(); 44 - 45 - return <ListsPage selectedListSlug={listSlug} />; 46 - }
+170 -291
apps/web/src/routes/lists.tsx apps/web/src/components/profile/ProfileListsPage.tsx
··· 1 1 import { 2 - authControllerMeOptions, 3 - listsControllerGetListQueryKey, 4 - listsControllerGetUserListsQueryKey, 2 + listsControllerGetPublicUserListOptions, 3 + listsControllerGetPublicUserListQueryKey, 4 + listsControllerGetPublicUserListsOptions, 5 + listsControllerGetPublicUserListsQueryKey, 5 6 listsControllerRemoveItemFromListMutation, 6 7 type MediaInListDto, 7 8 } from "@opnshelf/api"; 8 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 9 - import { 10 - createFileRoute, 11 - Outlet, 12 - redirect, 13 - useNavigate, 14 - } from "@tanstack/react-router"; 9 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 + import { useNavigate } from "@tanstack/react-router"; 15 11 import { 16 12 AlertCircle, 17 13 Clock, ··· 37 33 DialogTitle, 38 34 } from "#/components/ui/dialog"; 39 35 import { useAuth } from "#/lib/auth-context"; 40 - import { useCreateList, useList, useUserLists } from "#/lib/hooks"; 41 - import MediaCard from "../components/MediaCard"; 42 - 43 - export const LISTS_PAGE_TITLE = "Lists | OpnShelf"; 44 - export const LISTS_PAGE_DESCRIPTION = 45 - "Organize movies and shows into watchlists, favorites, and custom collections."; 46 - 47 - export const Route = createFileRoute("/lists")({ 48 - beforeLoad: async ({ context }) => { 49 - try { 50 - await context.queryClient.fetchQuery(authControllerMeOptions()); 51 - } catch (error: any) { 52 - if (error.status === 401 || error.statusCode === 401) { 53 - throw redirect({ 54 - to: "/login", 55 - search: { message: "Please log in to view your lists" }, 56 - }); 57 - } 58 - throw error; 59 - } 60 - }, 61 - component: ListsLayout, 62 - }); 36 + import { useCreateList } from "#/lib/hooks"; 37 + import MediaCard from "../../components/MediaCard"; 63 38 64 39 const colorClasses: Record<string, string> = { 65 40 blue: "bg-blue-500", ··· 82 57 gray: List, 83 58 }; 84 59 85 - // Helper to get color based on list name 86 60 function getListColor(name: string): string { 87 61 const nameLower = name.toLowerCase(); 88 62 if (nameLower.includes("watch") || nameLower.includes("later")) return "blue"; ··· 94 68 return "gray"; 95 69 } 96 70 97 - // Helper to format duration from runtime 98 71 function formatDuration(minutes?: number): string | undefined { 99 72 if (!minutes) return undefined; 100 73 const hours = Math.floor(minutes / 60); ··· 102 75 return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; 103 76 } 104 77 105 - // Helper to get poster URL from media data 106 78 function getPosterUrl(media: Record<string, unknown>): string { 107 79 if (media.poster_path && typeof media.poster_path === "string") { 108 80 return `https://image.tmdb.org/t/p/w500${media.poster_path}`; ··· 113 85 return ""; 114 86 } 115 87 116 - // Helper to get backdrop URL from media data 117 88 function getBackdropUrl(media: Record<string, unknown>): string | undefined { 118 89 if (media.backdrop_path && typeof media.backdrop_path === "string") { 119 90 return `https://image.tmdb.org/t/p/original${media.backdrop_path}`; ··· 124 95 return undefined; 125 96 } 126 97 127 - // Helper to get title from media data 128 98 function getTitle(media: Record<string, unknown>): string { 129 99 if (media.title && typeof media.title === "string") return media.title; 130 100 if (media.name && typeof media.name === "string") return media.name; 131 101 return "Unknown"; 132 102 } 133 103 134 - // Helper to get year from media data 135 104 function getYear(media: Record<string, unknown>): number | undefined { 136 105 if (media.release_date && typeof media.release_date === "string") { 137 106 return new Date(media.release_date).getFullYear(); ··· 145 114 return undefined; 146 115 } 147 116 148 - // Helper to get rating from media data 149 117 function getRating(media: Record<string, unknown>): number | undefined { 150 118 if (media.vote_average && typeof media.vote_average === "number") { 151 119 return media.vote_average; ··· 156 124 return undefined; 157 125 } 158 126 159 - export function buildListPageMeta( 160 - list?: { 161 - name: string; 162 - description?: string; 163 - total?: number; 164 - } | null, 165 - ) { 166 - if (!list) { 167 - return { 168 - title: LISTS_PAGE_TITLE, 169 - description: LISTS_PAGE_DESCRIPTION, 170 - }; 171 - } 172 - 173 - const itemLabel = 174 - typeof list.total === "number" 175 - ? `${list.total} item${list.total === 1 ? "" : "s"}` 176 - : "saved items"; 177 - 178 - return { 179 - title: `${list.name} | Lists | OpnShelf`, 180 - description: 181 - list.description?.trim() || 182 - `Browse ${itemLabel} in the ${list.name} list on OpnShelf.`, 183 - }; 127 + interface ProfileListsPageProps { 128 + userDid: string; 129 + handle: string; 130 + selectedListSlug?: string | null; 131 + isOwner: boolean; 184 132 } 185 133 186 - export function ListsPage({ 134 + export function ProfileListsPage({ 135 + userDid, 136 + handle, 187 137 selectedListSlug, 188 - }: { 189 - selectedListSlug?: string | null; 190 - }) { 191 - const { isAuthenticated, isLoading: authLoading } = useAuth(); 138 + isOwner, 139 + }: ProfileListsPageProps) { 192 140 const navigate = useNavigate(); 193 - 194 - // Redirect to login if not authenticated 195 - useEffect(() => { 196 - if (!authLoading && !isAuthenticated) { 197 - navigate({ to: "/login" }); 198 - } 199 - }, [authLoading, isAuthenticated, navigate]); 141 + const { isAuthenticated } = useAuth(); 200 142 201 143 const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); 202 144 const [showCreateModal, setShowCreateModal] = useState(false); ··· 204 146 const [newListDescription, setNewListDescription] = useState(""); 205 147 const [searchQuery, setSearchQuery] = useState(""); 206 148 207 - // Fetch user lists 149 + // Fetch public lists for this user 208 150 const { 209 151 data: userLists, 210 152 isLoading: listsLoading, 211 153 error: listsError, 212 - } = useUserLists(); 154 + } = useQuery({ 155 + ...listsControllerGetPublicUserListsOptions({ path: { userDid } }), 156 + enabled: !!userDid, 157 + }); 213 158 214 159 // Default to the first list when lists load and none is selected 215 160 useEffect(() => { 216 161 if (userLists && userLists.length > 0 && !selectedListSlug) { 217 162 navigate({ 218 - to: "/lists/$listSlug", 219 - params: { listSlug: userLists[0].slug }, 163 + to: "/profile/$handle/lists/$listSlug", 164 + params: { handle, listSlug: userLists[0].slug }, 220 165 replace: true, 221 166 }); 222 167 } 223 - }, [navigate, selectedListSlug, userLists]); 168 + }, [navigate, selectedListSlug, userLists, handle]); 224 169 225 - // Fetch selected list details with items 170 + // Fetch selected list details with items using public endpoint 226 171 const { 227 172 data: listDetails, 228 173 isLoading: listLoading, 229 174 error: listError, 230 - } = useList(selectedListSlug || ""); 175 + } = useQuery({ 176 + ...listsControllerGetPublicUserListOptions({ 177 + path: { userDid, slug: selectedListSlug || "" }, 178 + }), 179 + enabled: !!userDid && !!selectedListSlug, 180 + }); 231 181 232 - // Create list mutation 182 + // Create list mutation (only works for owner) 233 183 const createListMutation = useCreateList(); 234 184 const queryClient = useQueryClient(); 235 185 236 - // Remove item from list mutation 186 + // Remove item from list mutation (only works for owner) 237 187 const removeItemMutation = useMutation({ 238 188 ...listsControllerRemoveItemFromListMutation(), 239 189 onSuccess: () => { 240 190 if (selectedListSlug) { 241 191 queryClient.invalidateQueries({ 242 - queryKey: listsControllerGetListQueryKey({ 243 - path: { slug: selectedListSlug }, 192 + queryKey: listsControllerGetPublicUserListQueryKey({ 193 + path: { userDid, slug: selectedListSlug }, 244 194 }), 245 195 }); 246 196 } 247 197 queryClient.invalidateQueries({ 248 - queryKey: listsControllerGetUserListsQueryKey(), 198 + queryKey: listsControllerGetPublicUserListsQueryKey({ 199 + path: { userDid }, 200 + }), 249 201 }); 250 202 }, 251 203 }); ··· 269 221 270 222 const handleSelectList = (slug: string) => { 271 223 navigate({ 272 - to: "/lists/$listSlug", 273 - params: { listSlug: slug }, 224 + to: "/profile/$handle/lists/$listSlug", 225 + params: { handle, listSlug: slug }, 274 226 replace: true, 275 227 }); 276 228 }; ··· 290 242 setNewListDescription(""); 291 243 if (newList?.slug) { 292 244 navigate({ 293 - to: "/lists/$listSlug", 294 - params: { listSlug: newList.slug }, 245 + to: "/profile/$handle/lists/$listSlug", 246 + params: { handle, listSlug: newList.slug }, 295 247 replace: true, 296 248 }); 297 249 } ··· 303 255 // Show loading state 304 256 if (listsLoading) { 305 257 return ( 306 - <div className="container-app py-8"> 258 + <div className="py-8"> 307 259 <div className="flex h-64 items-center justify-center"> 308 260 <Loader2 className="h-8 w-8 animate-spin text-(--accent)" /> 309 261 <span className="ml-2 text-(--foreground-muted)"> ··· 317 269 // Show error state 318 270 if (listsError) { 319 271 return ( 320 - <div className="container-app py-8"> 272 + <div className="py-8"> 321 273 <div className="flex h-64 flex-col items-center justify-center gap-4"> 322 274 <AlertCircle className="h-12 w-12 text-red-500" /> 323 275 <div className="text-center"> ··· 341 293 // Show empty state when user has no lists 342 294 if (userLists && userLists.length === 0) { 343 295 return ( 344 - <div className="container-app py-8"> 296 + <div className="py-8"> 345 297 <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 346 298 <div className="flex items-center gap-3"> 347 299 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-(--accent-subtle) text-(--accent)"> ··· 350 302 <div> 351 303 <h1 className="text-display-2">Lists</h1> 352 304 <p className="text-(--foreground-muted)"> 353 - Organize and manage your collections 305 + Organize and manage collections 354 306 </p> 355 307 </div> 356 308 </div> 357 - 358 - <button 359 - type="button" 360 - onClick={() => setShowCreateModal(true)} 361 - className="btn btn-primary gap-2" 362 - > 363 - <Plus className="h-4 w-4" /> 364 - Create List 365 - </button> 366 309 </div> 367 310 368 311 <div className="flex h-96 flex-col items-center justify-center rounded-xl border-(--border) border-2 border-dashed"> ··· 373 316 No lists yet 374 317 </h3> 375 318 <p className="mt-1 max-w-md text-center text-(--foreground-muted)"> 376 - Create your first list to start organizing movies and shows you want 377 - to watch 319 + This user hasn&apos;t created any lists yet. 378 320 </p> 379 - <button 380 - type="button" 381 - onClick={() => setShowCreateModal(true)} 382 - className="btn btn-primary mt-4 gap-2" 383 - > 384 - <Plus className="h-4 w-4" /> 385 - Create Your First List 386 - </button> 387 321 </div> 388 - 389 - {/* Create List Modal */} 390 - <Dialog open={showCreateModal} onOpenChange={setShowCreateModal}> 391 - <DialogContent className="sm:max-w-[425px]"> 392 - <DialogHeader> 393 - <DialogTitle>Create New List</DialogTitle> 394 - <DialogDescription> 395 - Create a custom list to organize your movies and shows. 396 - </DialogDescription> 397 - </DialogHeader> 398 - <div className="space-y-4 py-4"> 399 - <div className="space-y-2"> 400 - <label htmlFor="list-name" className="font-medium text-sm"> 401 - List Name 402 - </label> 403 - <input 404 - id="list-name" 405 - type="text" 406 - placeholder="My Awesome List" 407 - className="input" 408 - value={newListName} 409 - onChange={(e) => setNewListName(e.target.value)} 410 - /> 411 - </div> 412 - <div className="space-y-2"> 413 - <label 414 - htmlFor="list-description" 415 - className="font-medium text-sm" 416 - > 417 - Description (optional) 418 - </label> 419 - <textarea 420 - id="list-description" 421 - placeholder="What's this list about?" 422 - className="input min-h-[80px] resize-none" 423 - value={newListDescription} 424 - onChange={(e) => setNewListDescription(e.target.value)} 425 - /> 426 - </div> 427 - </div> 428 - <div className="flex justify-end gap-2"> 429 - <Button 430 - variant="outline" 431 - onClick={() => setShowCreateModal(false)} 432 - > 433 - Cancel 434 - </Button> 435 - <Button 436 - onClick={handleCreateList} 437 - disabled={!newListName.trim() || createListMutation.isPending} 438 - > 439 - {createListMutation.isPending ? ( 440 - <> 441 - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 442 - Creating... 443 - </> 444 - ) : ( 445 - "Create List" 446 - )} 447 - </Button> 448 - </div> 449 - </DialogContent> 450 - </Dialog> 451 322 </div> 452 323 ); 453 324 } 454 325 455 326 return ( 456 - <div className="container-app py-8"> 327 + <div className="space-y-8"> 457 328 {/* Header */} 458 - <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 329 + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 459 330 <div className="flex items-center gap-3"> 460 331 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-(--accent-subtle) text-(--accent)"> 461 332 <List className="h-5 w-5" /> ··· 463 334 <div> 464 335 <h1 className="text-display-2">Lists</h1> 465 336 <p className="text-(--foreground-muted)"> 466 - Organize and manage your collections 337 + Organize and manage collections 467 338 </p> 468 339 </div> 469 340 </div> 470 341 471 - <button 472 - type="button" 473 - onClick={() => setShowCreateModal(true)} 474 - className="btn btn-primary gap-2" 475 - > 476 - <Plus className="h-4 w-4" /> 477 - Create List 478 - </button> 342 + {isOwner && isAuthenticated && ( 343 + <button 344 + type="button" 345 + onClick={() => setShowCreateModal(true)} 346 + className="btn btn-primary gap-2" 347 + > 348 + <Plus className="h-4 w-4" /> 349 + Create List 350 + </button> 351 + )} 479 352 </div> 480 353 481 354 <div className="grid gap-8 lg:grid-cols-4"> ··· 638 511 (viewMode === "grid" ? ( 639 512 <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 640 513 {filteredItems 641 - // Deduplicate by ID to prevent React key warnings 642 514 .filter( 643 515 (item, index, self) => 644 516 index === self.findIndex((i) => i.id === item.id), ··· 663 535 )} 664 536 size="md" 665 537 layout="poster" 666 - onRemove={() => 667 - removeItemMutation.mutate({ 668 - path: { 669 - slug: selectedListSlug || "", 670 - mediaType: item.mediaType, 671 - mediaId: item.mediaId, 672 - }, 673 - }) 674 - } 675 - isRemoving={ 676 - removeItemMutation.isPending && 677 - removeItemMutation.variables?.path?.mediaId === 678 - item.mediaId 679 - } 538 + {...(isOwner 539 + ? { 540 + onRemove: () => 541 + removeItemMutation.mutate({ 542 + path: { 543 + slug: selectedListSlug || "", 544 + mediaType: item.mediaType, 545 + mediaId: item.mediaId, 546 + }, 547 + }), 548 + isRemoving: 549 + removeItemMutation.isPending && 550 + removeItemMutation.variables?.path 551 + ?.mediaId === item.mediaId, 552 + } 553 + : {})} 680 554 /> 681 555 ))} 682 556 </div> 683 557 ) : ( 684 558 <div className="space-y-2"> 685 559 {filteredItems 686 - // Deduplicate by ID to prevent React key warnings 687 560 .filter( 688 561 (item, index, self) => 689 562 index === self.findIndex((i) => i.id === item.id), ··· 727 600 )} 728 601 {getRating(item.media) && ( 729 602 <> 730 - <span>•</span> 603 + <span>&bull;</span> 731 604 <span className="flex items-center gap-1"> 732 605 <Star className="h-3 w-3 fill-current text-yellow-500" /> 733 606 {getRating(item.media)?.toFixed(1)} ··· 738 611 item.media.runtime as number | undefined, 739 612 ) && ( 740 613 <> 741 - <span>•</span> 614 + <span>&bull;</span> 742 615 <span> 743 616 {formatDuration( 744 617 item.media.runtime as number | undefined, ··· 748 621 )} 749 622 </div> 750 623 </div> 751 - <button 752 - type="button" 753 - onClick={() => 754 - removeItemMutation.mutate({ 755 - path: { 756 - slug: selectedListSlug || "", 757 - mediaType: item.mediaType, 758 - mediaId: item.mediaId, 759 - }, 760 - }) 761 - } 762 - disabled={ 763 - removeItemMutation.isPending && 624 + {isOwner && ( 625 + <button 626 + type="button" 627 + onClick={() => 628 + removeItemMutation.mutate({ 629 + path: { 630 + slug: selectedListSlug || "", 631 + mediaType: item.mediaType, 632 + mediaId: item.mediaId, 633 + }, 634 + }) 635 + } 636 + disabled={ 637 + removeItemMutation.isPending && 638 + removeItemMutation.variables?.path?.mediaId === 639 + item.mediaId 640 + } 641 + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--background-elevated) text-(--foreground-muted) transition-colors hover:border-red-300 hover:bg-red-500/10 hover:text-red-500 disabled:opacity-50" 642 + aria-label="Remove from list" 643 + > 644 + {removeItemMutation.isPending && 764 645 removeItemMutation.variables?.path?.mediaId === 765 - item.mediaId 766 - } 767 - className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--background-elevated) text-(--foreground-muted) transition-colors hover:border-red-300 hover:bg-red-500/10 hover:text-red-500 disabled:opacity-50" 768 - aria-label="Remove from list" 769 - > 770 - {removeItemMutation.isPending && 771 - removeItemMutation.variables?.path?.mediaId === 772 - item.mediaId ? ( 773 - <Loader2 className="h-4 w-4 animate-spin" /> 774 - ) : ( 775 - <X className="h-4 w-4" /> 776 - )} 777 - </button> 646 + item.mediaId ? ( 647 + <Loader2 className="h-4 w-4 animate-spin" /> 648 + ) : ( 649 + <X className="h-4 w-4" /> 650 + )} 651 + </button> 652 + )} 778 653 </div> 779 654 ))} 780 655 </div> ··· 797 672 </div> 798 673 799 674 {/* Create List Modal */} 800 - <Dialog open={showCreateModal} onOpenChange={setShowCreateModal}> 801 - <DialogContent className="sm:max-w-[425px]"> 802 - <DialogHeader> 803 - <DialogTitle>Create New List</DialogTitle> 804 - <DialogDescription> 805 - Create a custom list to organize your movies and shows. 806 - </DialogDescription> 807 - </DialogHeader> 808 - <div className="space-y-4 py-4"> 809 - <div className="space-y-2"> 810 - <label htmlFor="list-name" className="font-medium text-sm"> 811 - List Name 812 - </label> 813 - <input 814 - id="list-name" 815 - type="text" 816 - placeholder="My Awesome List" 817 - className="input" 818 - value={newListName} 819 - onChange={(e) => setNewListName(e.target.value)} 820 - /> 675 + {isOwner && ( 676 + <Dialog open={showCreateModal} onOpenChange={setShowCreateModal}> 677 + <DialogContent className="sm:max-w-[425px]"> 678 + <DialogHeader> 679 + <DialogTitle>Create New List</DialogTitle> 680 + <DialogDescription> 681 + Create a custom list to organize your movies and shows. 682 + </DialogDescription> 683 + </DialogHeader> 684 + <div className="space-y-4 py-4"> 685 + <div className="space-y-2"> 686 + <label htmlFor="list-name" className="font-medium text-sm"> 687 + List Name 688 + </label> 689 + <input 690 + id="list-name" 691 + type="text" 692 + placeholder="My Awesome List" 693 + className="input" 694 + value={newListName} 695 + onChange={(e) => setNewListName(e.target.value)} 696 + /> 697 + </div> 698 + <div className="space-y-2"> 699 + <label 700 + htmlFor="list-description" 701 + className="font-medium text-sm" 702 + > 703 + Description (optional) 704 + </label> 705 + <textarea 706 + id="list-description" 707 + placeholder="What's this list about?" 708 + className="input min-h-[80px] resize-none" 709 + value={newListDescription} 710 + onChange={(e) => setNewListDescription(e.target.value)} 711 + /> 712 + </div> 821 713 </div> 822 - <div className="space-y-2"> 823 - <label htmlFor="list-description" className="font-medium text-sm"> 824 - Description (optional) 825 - </label> 826 - <textarea 827 - id="list-description" 828 - placeholder="What's this list about?" 829 - className="input min-h-[80px] resize-none" 830 - value={newListDescription} 831 - onChange={(e) => setNewListDescription(e.target.value)} 832 - /> 714 + <div className="flex justify-end gap-2"> 715 + <Button 716 + variant="outline" 717 + onClick={() => setShowCreateModal(false)} 718 + > 719 + Cancel 720 + </Button> 721 + <Button 722 + onClick={handleCreateList} 723 + disabled={!newListName.trim() || createListMutation.isPending} 724 + > 725 + {createListMutation.isPending ? ( 726 + <> 727 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 728 + Creating... 729 + </> 730 + ) : ( 731 + "Create List" 732 + )} 733 + </Button> 833 734 </div> 834 - </div> 835 - <div className="flex justify-end gap-2"> 836 - <Button variant="outline" onClick={() => setShowCreateModal(false)}> 837 - Cancel 838 - </Button> 839 - <Button 840 - onClick={handleCreateList} 841 - disabled={!newListName.trim() || createListMutation.isPending} 842 - > 843 - {createListMutation.isPending ? ( 844 - <> 845 - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 846 - Creating... 847 - </> 848 - ) : ( 849 - "Create List" 850 - )} 851 - </Button> 852 - </div> 853 - </DialogContent> 854 - </Dialog> 735 + </DialogContent> 736 + </Dialog> 737 + )} 855 738 </div> 856 739 ); 857 740 } 858 - 859 - function ListsLayout() { 860 - return <Outlet />; 861 - }
-19
apps/web/src/routes/lists/index.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { LISTS_PAGE_DESCRIPTION, LISTS_PAGE_TITLE, ListsPage } from "../lists"; 3 - 4 - export const Route = createFileRoute("/lists/")({ 5 - component: ListsIndexPage, 6 - head: () => ({ 7 - meta: [ 8 - { title: LISTS_PAGE_TITLE }, 9 - { 10 - name: "description", 11 - content: LISTS_PAGE_DESCRIPTION, 12 - }, 13 - ], 14 - }), 15 - }); 16 - 17 - function ListsIndexPage() { 18 - return <ListsPage />; 19 - }
+8 -103
apps/web/src/routes/movies/$movieId/$movieName.tsx
··· 27 27 import MediaHero from "../../../components/MediaHero"; 28 28 import PersonGrid from "../../../components/PersonGrid"; 29 29 import SimilarMediaGrid from "../../../components/SimilarMediaGrid"; 30 + import { YourActivity } from "../../../components/YourActivity"; 30 31 31 32 setupApiClient(); 32 33 ··· 60 61 return `${hours}h ${mins}m`; 61 62 } 62 63 63 - function formatDateTime( 64 - dateString: string, 65 - timezone?: string, 66 - timeFormat?: "12h" | "24h", 67 - ): string { 68 - if (!dateString) return "Unknown"; 69 - try { 70 - return new Date(dateString).toLocaleString( 71 - "en-US", 72 - withUserLocale( 73 - { 74 - month: "short", 75 - day: "numeric", 76 - year: "numeric", 77 - hour: "numeric", 78 - minute: "2-digit", 79 - }, 80 - timezone, 81 - timeFormat, 82 - ), 83 - ); 84 - } catch { 85 - return dateString; 86 - } 87 - } 88 - 89 64 function MovieDetailPage() { 90 65 const { movieId } = Route.useParams(); 91 66 const { userSettings } = useAuth(); 92 67 const userTimezone = userSettings?.timezone; 93 - const userTimeFormat = userSettings?.timeFormat; 94 68 95 69 const { data: movie, isLoading, error } = useMovieDetails(movieId); 96 70 const { data: similarMoviesData } = useDiscoverMovies(1); ··· 300 274 /> 301 275 302 276 {/* Your Activity */} 303 - <section className="card p-5"> 304 - <h3 className="mb-4 font-display font-semibold">Your Activity</h3> 305 - {movieWatchHistory && 306 - Array.isArray(movieWatchHistory) && 307 - movieWatchHistory.length > 0 ? ( 308 - <div className="space-y-1"> 309 - {movieWatchHistory.map((entry, index) => ( 310 - <div 311 - key={entry.id || index} 312 - className="group flex items-center rounded-lg transition-colors hover:bg-(--background-subtle)" 313 - > 314 - <div className="flex flex-1 items-center p-2"> 315 - <span className="font-medium text-sm"> 316 - {formatDateTime( 317 - entry.watchedDate, 318 - userTimezone, 319 - userTimeFormat, 320 - )} 321 - </span> 322 - </div> 323 - <button 324 - type="button" 325 - onClick={() => deleteMovieWatchHistoryEntry(entry.id)} 326 - disabled={isDeleteMovieHistoryPending} 327 - className="flex h-8 w-8 items-center justify-center rounded-md text-(--foreground-muted) transition-colors hover:bg-red-500/10 hover:text-red-500" 328 - aria-label="Remove this play" 329 - > 330 - <X className="h-4 w-4" /> 331 - </button> 332 - </div> 333 - ))} 334 - <button 335 - type="button" 336 - onClick={markMovieWatched} 337 - disabled={isMarkMoviePending} 338 - className="btn btn-secondary mt-3 w-full gap-2" 339 - > 340 - {isMarkMoviePending ? ( 341 - <> 342 - <Loader2 className="h-4 w-4 animate-spin" /> 343 - Loading 344 - </> 345 - ) : ( 346 - <> 347 - <Plus className="h-4 w-4" /> 348 - Add to shelf 349 - </> 350 - )} 351 - </button> 352 - </div> 353 - ) : ( 354 - <div className="space-y-3"> 355 - <p className="text-(--foreground-muted) text-sm"> 356 - You haven&apos;t watched this yet 357 - </p> 358 - <button 359 - type="button" 360 - onClick={markMovieWatched} 361 - disabled={isMarkMoviePending} 362 - className="btn btn-secondary w-full gap-2 text-sm" 363 - > 364 - {isMarkMoviePending ? ( 365 - <> 366 - <Loader2 className="h-4 w-4 animate-spin" /> 367 - Loading 368 - </> 369 - ) : ( 370 - <> 371 - <Plus className="h-4 w-4" /> 372 - Add to shelf 373 - </> 374 - )} 375 - </button> 376 - </div> 377 - )} 378 - </section> 277 + <YourActivity 278 + watchHistory={movieWatchHistory || []} 279 + onAddToShelf={markMovieWatched} 280 + onDeleteEntry={deleteMovieWatchHistoryEntry} 281 + isAddPending={isMarkMoviePending} 282 + isDeletePending={isDeleteMovieHistoryPending} 283 + /> 379 284 380 285 <InYourLists mediaType="movie" mediaId={movieId} /> 381 286 </div>
+131
apps/web/src/routes/profile.$handle.tsx
··· 1 + import { usersControllerGetPublicProfileOptions } from "@opnshelf/api"; 2 + import { 3 + createFileRoute, 4 + Link, 5 + notFound, 6 + Outlet, 7 + useParams, 8 + } from "@tanstack/react-router"; 9 + import { Clock, Film, LayoutGrid, List, Users } from "lucide-react"; 10 + import { useAuth } from "#/lib/auth-context"; 11 + 12 + export const Route = createFileRoute("/profile/$handle")({ 13 + loader: async ({ context, params }) => { 14 + try { 15 + const profile = await context.queryClient.ensureQueryData( 16 + usersControllerGetPublicProfileOptions({ 17 + path: { handle: params.handle }, 18 + }), 19 + ); 20 + return { profile }; 21 + } catch (error: any) { 22 + if (error.status === 404 || error.statusCode === 404) { 23 + throw notFound(); 24 + } 25 + throw error; 26 + } 27 + }, 28 + head: ({ loaderData }) => { 29 + const name = 30 + loaderData?.profile.displayName || loaderData?.profile.handle || "User"; 31 + return { 32 + meta: [ 33 + { title: `${name} | OpnShelf` }, 34 + { 35 + name: "description", 36 + content: `View ${name}'s shelf, lists, and activity on OpnShelf.`, 37 + }, 38 + ], 39 + }; 40 + }, 41 + component: ProfileLayout, 42 + }); 43 + 44 + const tabs = [ 45 + { label: "Overview", to: "/profile/$handle", icon: LayoutGrid, exact: true }, 46 + { label: "Shelf", to: "/profile/$handle/shelf", icon: Film }, 47 + { label: "Up Next", to: "/profile/$handle/up-next", icon: Clock }, 48 + { label: "Lists", to: "/profile/$handle/lists", icon: List }, 49 + { label: "Connections", to: "/profile/$handle/connections", icon: Users }, 50 + ]; 51 + 52 + function ProfileLayout() { 53 + const { handle } = useParams({ from: "/profile/$handle" }); 54 + const { profile } = Route.useLoaderData(); 55 + const { user } = useAuth(); 56 + const isOwner = user?.did === profile.did; 57 + 58 + return ( 59 + <div className="container-app py-8"> 60 + {/* Profile Header */} 61 + <div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-center"> 62 + {/* Avatar */} 63 + <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--background-elevated)"> 64 + {profile.avatar ? ( 65 + <img 66 + src={profile.avatar} 67 + alt={profile.displayName || profile.handle} 68 + className="h-full w-full object-cover" 69 + /> 70 + ) : ( 71 + <Users className="h-8 w-8 text-(--foreground-muted)" /> 72 + )} 73 + </div> 74 + 75 + {/* Name & Handle */} 76 + <div className="flex-1"> 77 + <div className="flex items-center gap-2"> 78 + <h1 className="text-display-2"> 79 + {profile.displayName || profile.handle} 80 + </h1> 81 + {isOwner && <span className="badge badge-subtle text-xs">You</span>} 82 + </div> 83 + <p className="text-(--foreground-muted)">@{profile.handle}</p> 84 + </div> 85 + 86 + {/* Stats */} 87 + <div className="flex gap-6"> 88 + <div className="text-center"> 89 + <p className="font-semibold text-lg">{profile.followersCount}</p> 90 + <p className="text-(--foreground-muted) text-sm">Followers</p> 91 + </div> 92 + <div className="text-center"> 93 + <p className="font-semibold text-lg">{profile.followingCount}</p> 94 + <p className="text-(--foreground-muted) text-sm">Following</p> 95 + </div> 96 + </div> 97 + </div> 98 + 99 + {/* Tab Navigation */} 100 + <div className="mb-8 border-(--border) border-b"> 101 + <nav className="flex gap-1 overflow-x-auto"> 102 + {tabs.map((tab) => { 103 + const Icon = tab.icon; 104 + return ( 105 + <Link 106 + key={tab.label} 107 + to={tab.to} 108 + params={{ handle }} 109 + activeOptions={tab.exact ? { exact: true } : undefined} 110 + activeProps={{ 111 + className: "border-(--accent) text-(--accent)", 112 + }} 113 + inactiveProps={{ 114 + className: 115 + "border-transparent text-(--foreground-muted) hover:text-(--foreground) hover:border-(--border-strong)", 116 + }} 117 + className="flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 font-medium text-sm transition-colors" 118 + > 119 + <Icon className="h-4 w-4" /> 120 + {tab.label} 121 + </Link> 122 + ); 123 + })} 124 + </nav> 125 + </div> 126 + 127 + {/* Child Route Content */} 128 + <Outlet /> 129 + </div> 130 + ); 131 + }
+110
apps/web/src/routes/profile.$handle/connections.tsx
··· 1 + import { usersControllerGetPublicProfileOptions } from "@opnshelf/api"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, Link } from "@tanstack/react-router"; 4 + import { Loader2, Users } from "lucide-react"; 5 + import { useState } from "react"; 6 + import { 7 + usePublicFollowers, 8 + usePublicFollowing, 9 + } from "#/lib/hooks/usePublicProfile"; 10 + 11 + export const Route = createFileRoute("/profile/$handle/connections")({ 12 + component: ProfileConnectionsPage, 13 + }); 14 + 15 + function ProfileConnectionsPage() { 16 + const { handle } = Route.useParams(); 17 + const [activeTab, setActiveTab] = useState<"followers" | "following">( 18 + "followers", 19 + ); 20 + 21 + const { data: profile } = useQuery({ 22 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 23 + }); 24 + const displayName = profile?.displayName || profile?.handle || handle; 25 + 26 + const followersQuery = usePublicFollowers(handle); 27 + const followingQuery = usePublicFollowing(handle); 28 + 29 + const activeQuery = 30 + activeTab === "followers" ? followersQuery : followingQuery; 31 + 32 + return ( 33 + <div className="space-y-6"> 34 + <h1 className="text-display-2">{displayName}&apos;s Connections</h1> 35 + 36 + {/* Sub-tabs */} 37 + <div className="flex gap-2 border-(--border) border-b"> 38 + <button 39 + type="button" 40 + onClick={() => setActiveTab("followers")} 41 + className={`border-b-2 px-4 py-2 font-medium text-sm transition-colors ${ 42 + activeTab === "followers" 43 + ? "border-(--accent) text-(--accent)" 44 + : "border-transparent text-(--foreground-muted) hover:text-(--foreground)" 45 + }`} 46 + > 47 + Followers ({profile?.followersCount ?? 0}) 48 + </button> 49 + <button 50 + type="button" 51 + onClick={() => setActiveTab("following")} 52 + className={`border-b-2 px-4 py-2 font-medium text-sm transition-colors ${ 53 + activeTab === "following" 54 + ? "border-(--accent) text-(--accent)" 55 + : "border-transparent text-(--foreground-muted) hover:text-(--foreground)" 56 + }`} 57 + > 58 + Following ({profile?.followingCount ?? 0}) 59 + </button> 60 + </div> 61 + 62 + {/* Content */} 63 + {activeQuery.isLoading ? ( 64 + <div className="flex h-64 items-center justify-center"> 65 + <Loader2 className="h-8 w-8 animate-spin text-(--accent)" /> 66 + </div> 67 + ) : !activeQuery.data || activeQuery.data.items.length === 0 ? ( 68 + <div className="card p-8 text-center"> 69 + <Users className="mx-auto mb-3 h-12 w-12 text-(--foreground-muted)" /> 70 + <p className="text-(--foreground-muted)"> 71 + {activeTab === "followers" 72 + ? "No followers yet." 73 + : "Not following anyone yet."} 74 + </p> 75 + </div> 76 + ) : ( 77 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 78 + {activeQuery.data.items.map((user) => ( 79 + <Link 80 + key={user.did} 81 + to="/profile/$handle" 82 + params={{ handle: user.handle }} 83 + className="card card-interactive flex items-center gap-4 p-4" 84 + > 85 + <div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--background-elevated)"> 86 + {typeof user.avatar === "string" ? ( 87 + <img 88 + src={user.avatar} 89 + alt={String(user.displayName || user.handle)} 90 + className="h-full w-full object-cover" 91 + /> 92 + ) : ( 93 + <Users className="h-5 w-5 text-(--foreground-muted)" /> 94 + )} 95 + </div> 96 + <div className="min-w-0 flex-1"> 97 + <p className="truncate font-medium"> 98 + {String(user.displayName || user.handle)} 99 + </p> 100 + <p className="text-(--foreground-muted) text-sm"> 101 + @{user.handle} 102 + </p> 103 + </div> 104 + </Link> 105 + ))} 106 + </div> 107 + )} 108 + </div> 109 + ); 110 + }
+476
apps/web/src/routes/profile.$handle/index.tsx
··· 1 + import { 2 + listsControllerGetPublicUserListsOptions, 3 + moviesControllerGetUserMoviesPaginatedOptions, 4 + moviesControllerUnmarkWatchedMutation, 5 + shelfControllerGetUserShelfOptions, 6 + shelfControllerGetUserShelfQueryKey, 7 + showsControllerGetUserEpisodesPaginatedOptions, 8 + showsControllerUnmarkWatchedMutation, 9 + usersControllerGetPublicProfileOptions, 10 + } from "@opnshelf/api"; 11 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 12 + import { createFileRoute, Link } from "@tanstack/react-router"; 13 + import { 14 + ChevronRight, 15 + Clock, 16 + Film, 17 + Heart, 18 + List, 19 + Loader2, 20 + Tv, 21 + X, 22 + } from "lucide-react"; 23 + import { setupApiClient } from "#/lib/api"; 24 + import { useAuth } from "#/lib/auth-context"; 25 + import { toSlug } from "#/lib/slug"; 26 + 27 + setupApiClient(); 28 + 29 + export const Route = createFileRoute("/profile/$handle/")({ 30 + component: ProfileOverviewPage, 31 + }); 32 + 33 + function formatWatchedDate(dateStr?: string): string { 34 + if (!dateStr) return ""; 35 + const date = new Date(dateStr); 36 + return date.toLocaleDateString("en-US", { 37 + month: "short", 38 + day: "numeric", 39 + year: 40 + date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", 41 + }); 42 + } 43 + 44 + function ProfileOverviewPage() { 45 + const { handle } = Route.useParams(); 46 + const { user } = useAuth(); 47 + const queryClient = useQueryClient(); 48 + 49 + const { data: profile } = useQuery({ 50 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 51 + }); 52 + const userDid = profile?.did || ""; 53 + const displayName = profile?.displayName || profile?.handle || handle; 54 + const isOwner = user?.did === userDid; 55 + 56 + // Fetch recent shelf items (mixed, we'll split client-side for overview) 57 + const { data: shelfData, isLoading: shelfLoading } = useQuery({ 58 + ...shelfControllerGetUserShelfOptions({ 59 + path: { userDid }, 60 + query: { page: 1, pageSize: 24 }, 61 + }), 62 + enabled: !!userDid, 63 + }); 64 + 65 + const movies = 66 + shelfData?.items?.filter((item) => item.type === "movie").slice(0, 6) ?? []; 67 + const episodes = 68 + shelfData?.items?.filter((item) => item.type === "episode").slice(0, 6) ?? 69 + []; 70 + 71 + // Fetch public lists 72 + const { data: listsData, isLoading: listsLoading } = useQuery({ 73 + ...listsControllerGetPublicUserListsOptions({ 74 + path: { userDid }, 75 + }), 76 + enabled: !!userDid, 77 + }); 78 + 79 + // Fetch total counts 80 + const { data: moviesCountData } = useQuery({ 81 + ...moviesControllerGetUserMoviesPaginatedOptions({ 82 + path: { userDid }, 83 + query: { limit: 1 }, 84 + }), 85 + enabled: !!userDid, 86 + }); 87 + const { data: episodesCountData } = useQuery({ 88 + ...showsControllerGetUserEpisodesPaginatedOptions({ 89 + path: { userDid }, 90 + query: { limit: 1 }, 91 + }), 92 + enabled: !!userDid, 93 + }); 94 + 95 + const watchlist = listsData?.find((l) => l.slug === "watchlist"); 96 + const favorites = listsData?.find((l) => l.slug === "favorites"); 97 + 98 + const totalMovies = moviesCountData?.total ?? 0; 99 + const totalEpisodes = episodesCountData?.total ?? 0; 100 + const totalLists = listsData?.length ?? 0; 101 + const totalWatched = shelfData?.total ?? 0; 102 + 103 + // Mutations for removing from shelf 104 + const removeMovieMutation = useMutation({ 105 + ...moviesControllerUnmarkWatchedMutation(), 106 + onSuccess: () => { 107 + queryClient.invalidateQueries({ 108 + queryKey: shelfControllerGetUserShelfQueryKey({ 109 + path: { userDid }, 110 + }), 111 + }); 112 + }, 113 + }); 114 + const removeEpisodeMutation = useMutation({ 115 + ...showsControllerUnmarkWatchedMutation(), 116 + onSuccess: () => { 117 + queryClient.invalidateQueries({ 118 + queryKey: shelfControllerGetUserShelfQueryKey({ 119 + path: { userDid }, 120 + }), 121 + }); 122 + }, 123 + }); 124 + 125 + return ( 126 + <div className="space-y-10"> 127 + {/* Stats Row */} 128 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 129 + <StatCard 130 + label="Movies" 131 + value={totalMovies} 132 + icon={Film} 133 + isLoading={!moviesCountData && !!userDid} 134 + /> 135 + <StatCard 136 + label="Episodes" 137 + value={totalEpisodes} 138 + icon={Tv} 139 + isLoading={!episodesCountData && !!userDid} 140 + /> 141 + <StatCard 142 + label="Lists" 143 + value={totalLists} 144 + icon={List} 145 + isLoading={listsLoading} 146 + /> 147 + <StatCard 148 + label="Watched" 149 + value={totalWatched} 150 + icon={Clock} 151 + isLoading={shelfLoading} 152 + /> 153 + </div> 154 + 155 + {/* Last 6 Movies */} 156 + <section> 157 + <div className="mb-4 flex items-center justify-between"> 158 + <h2 className="flex items-center gap-2 text-display-3"> 159 + <Film className="h-5 w-5 text-(--accent)" /> 160 + Last Movies 161 + </h2> 162 + <Link 163 + to="/profile/$handle/shelf" 164 + params={{ handle }} 165 + search={{ type: "movie" }} 166 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 167 + > 168 + View all 169 + <ChevronRight className="h-4 w-4" /> 170 + </Link> 171 + </div> 172 + 173 + {shelfLoading ? ( 174 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 175 + {[1, 2, 3, 4, 5, 6].map((i) => ( 176 + <div 177 + key={i} 178 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 179 + /> 180 + ))} 181 + </div> 182 + ) : movies.length > 0 ? ( 183 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 184 + {movies.map((item) => ( 185 + <ShelfItemCard 186 + key={item.id} 187 + item={item} 188 + isOwner={isOwner} 189 + onRemove={() => 190 + removeMovieMutation.mutate({ 191 + path: { movieId: item.movieId }, 192 + query: { mode: "all" }, 193 + }) 194 + } 195 + isRemoving={removeMovieMutation.isPending} 196 + /> 197 + ))} 198 + </div> 199 + ) : ( 200 + <div className="card p-8 text-center"> 201 + <p className="text-(--foreground-muted)"> 202 + {displayName} hasn&apos;t watched any movies yet. 203 + </p> 204 + </div> 205 + )} 206 + </section> 207 + 208 + {/* Last 6 Episodes */} 209 + <section> 210 + <div className="mb-4 flex items-center justify-between"> 211 + <h2 className="flex items-center gap-2 text-display-3"> 212 + <Tv className="h-5 w-5 text-(--accent)" /> 213 + Last Episodes 214 + </h2> 215 + <Link 216 + to="/profile/$handle/shelf" 217 + params={{ handle }} 218 + search={{ type: "episode" }} 219 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 220 + > 221 + View all 222 + <ChevronRight className="h-4 w-4" /> 223 + </Link> 224 + </div> 225 + 226 + {shelfLoading ? ( 227 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 228 + {[1, 2, 3, 4, 5, 6].map((i) => ( 229 + <div 230 + key={i} 231 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 232 + /> 233 + ))} 234 + </div> 235 + ) : episodes.length > 0 ? ( 236 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> 237 + {episodes.map((item) => ( 238 + <ShelfItemCard 239 + key={item.id} 240 + item={item} 241 + isOwner={isOwner} 242 + onRemove={() => 243 + removeEpisodeMutation.mutate({ 244 + path: { showId: item.showId }, 245 + query: { 246 + seasonNumber: item.seasonNumber, 247 + episodeNumber: item.episodeNumber, 248 + mode: "all", 249 + }, 250 + }) 251 + } 252 + isRemoving={removeEpisodeMutation.isPending} 253 + /> 254 + ))} 255 + </div> 256 + ) : ( 257 + <div className="card p-8 text-center"> 258 + <p className="text-(--foreground-muted)"> 259 + {displayName} hasn&apos;t watched any episodes yet. 260 + </p> 261 + </div> 262 + )} 263 + </section> 264 + 265 + {/* Lists Preview */} 266 + <div className="grid gap-8 lg:grid-cols-2"> 267 + <ListPreview 268 + title="Watchlist" 269 + list={watchlist} 270 + handle={handle} 271 + isLoading={listsLoading} 272 + icon={Clock} 273 + emptyText="Nothing on watchlist" 274 + /> 275 + <ListPreview 276 + title="Favorites" 277 + list={favorites} 278 + handle={handle} 279 + isLoading={listsLoading} 280 + icon={Heart} 281 + emptyText="Nothing on favorites" 282 + /> 283 + </div> 284 + </div> 285 + ); 286 + } 287 + 288 + function ShelfItemCard({ 289 + item, 290 + isOwner, 291 + onRemove, 292 + isRemoving, 293 + }: { 294 + item: { 295 + id: string; 296 + type: "movie" | "episode"; 297 + posterPath?: string; 298 + watchedDate?: string; 299 + } & Record<string, unknown>; 300 + isOwner: boolean; 301 + onRemove: () => void; 302 + isRemoving: boolean; 303 + }) { 304 + const isMovie = item.type === "movie"; 305 + const title = isMovie ? (item.title as string) : (item.showTitle as string); 306 + const id = isMovie ? (item.movieId as string) : (item.showId as string); 307 + const year = isMovie 308 + ? (item.releaseYear as number | undefined) 309 + : (item.firstAirYear as number | undefined); 310 + 311 + const episodeInfo = !isMovie 312 + ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 313 + : undefined; 314 + 315 + return ( 316 + <div className="group relative"> 317 + <Link 318 + to={ 319 + isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName" 320 + } 321 + params={ 322 + isMovie 323 + ? { movieId: id, movieName: toSlug(title) } 324 + : { showId: id, showName: toSlug(title) } 325 + } 326 + className="block" 327 + > 328 + <div className="aspect-[2/3] overflow-hidden rounded-lg bg-(--background-subtle)"> 329 + {item.posterPath ? ( 330 + <img 331 + src={`https://image.tmdb.org/t/p/w500${item.posterPath}`} 332 + alt={title} 333 + className="h-full w-full object-cover transition-transform group-hover:scale-105" 334 + loading="lazy" 335 + /> 336 + ) : ( 337 + <div className="flex h-full w-full items-center justify-center"> 338 + {isMovie ? ( 339 + <Film className="h-8 w-8 text-(--foreground-muted)" /> 340 + ) : ( 341 + <Tv className="h-8 w-8 text-(--foreground-muted)" /> 342 + )} 343 + </div> 344 + )} 345 + </div> 346 + <div className="mt-2"> 347 + <p className="truncate font-medium text-sm">{title}</p> 348 + <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 349 + {year && <span>{year}</span>} 350 + {episodeInfo && <span>{episodeInfo}</span>} 351 + {item.watchedDate && ( 352 + <span>{formatWatchedDate(item.watchedDate)}</span> 353 + )} 354 + </div> 355 + </div> 356 + </Link> 357 + 358 + {/* Remove button */} 359 + {isOwner && ( 360 + <button 361 + type="button" 362 + onClick={(e) => { 363 + e.preventDefault(); 364 + e.stopPropagation(); 365 + onRemove(); 366 + }} 367 + disabled={isRemoving} 368 + className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-red-500 disabled:opacity-100 group-hover:opacity-100" 369 + aria-label="Remove from shelf" 370 + > 371 + {isRemoving ? ( 372 + <Loader2 className="h-3.5 w-3.5 animate-spin" /> 373 + ) : ( 374 + <X className="h-3.5 w-3.5" /> 375 + )} 376 + </button> 377 + )} 378 + </div> 379 + ); 380 + } 381 + 382 + function StatCard({ 383 + label, 384 + value, 385 + icon: Icon, 386 + isLoading, 387 + }: { 388 + label: string; 389 + value: number; 390 + icon: React.ComponentType<{ className?: string }>; 391 + isLoading: boolean; 392 + }) { 393 + return ( 394 + <div className="card p-4"> 395 + <div className="flex items-center gap-3"> 396 + <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-(--accent-subtle) text-(--accent)"> 397 + <Icon className="h-5 w-5" /> 398 + </div> 399 + <div> 400 + {isLoading ? ( 401 + <div className="h-6 w-8 animate-pulse rounded bg-(--background-subtle)" /> 402 + ) : ( 403 + <p className="font-semibold text-lg">{value}</p> 404 + )} 405 + <p className="text-(--foreground-muted) text-sm">{label}</p> 406 + </div> 407 + </div> 408 + </div> 409 + ); 410 + } 411 + 412 + function ListPreview({ 413 + title, 414 + list, 415 + handle, 416 + isLoading, 417 + icon: Icon, 418 + emptyText, 419 + }: { 420 + title: string; 421 + list?: { slug: string; itemCount: number }; 422 + handle: string; 423 + isLoading: boolean; 424 + icon: React.ComponentType<{ className?: string }>; 425 + emptyText: string; 426 + }) { 427 + return ( 428 + <section> 429 + <div className="mb-4 flex items-center justify-between"> 430 + <h2 className="flex items-center gap-2 text-display-3"> 431 + <Icon className="h-5 w-5 text-(--accent)" /> 432 + {title} 433 + </h2> 434 + {list && ( 435 + <Link 436 + to="/profile/$handle/lists/$listSlug" 437 + params={{ handle, listSlug: list.slug }} 438 + className="flex items-center gap-1 font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 439 + > 440 + View all 441 + <ChevronRight className="h-4 w-4" /> 442 + </Link> 443 + )} 444 + </div> 445 + 446 + {isLoading ? ( 447 + <div className="grid grid-cols-3 gap-4"> 448 + {[1, 2, 3].map((i) => ( 449 + <div 450 + key={i} 451 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 452 + /> 453 + ))} 454 + </div> 455 + ) : list && list.itemCount > 0 ? ( 456 + <Link 457 + to="/profile/$handle/lists/$listSlug" 458 + params={{ handle, listSlug: list.slug }} 459 + className="card card-interactive flex items-center justify-between p-4" 460 + > 461 + <div> 462 + <h3 className="font-semibold">{title}</h3> 463 + <p className="text-(--foreground-muted) text-sm"> 464 + {list.itemCount} item{list.itemCount === 1 ? "" : "s"} 465 + </p> 466 + </div> 467 + <ChevronRight className="h-5 w-5 text-(--foreground-muted)" /> 468 + </Link> 469 + ) : ( 470 + <div className="card p-6 text-center"> 471 + <p className="text-(--foreground-muted)">{emptyText}</p> 472 + </div> 473 + )} 474 + </section> 475 + ); 476 + }
+30
apps/web/src/routes/profile.$handle/lists.$listSlug.tsx
··· 1 + import { usersControllerGetPublicProfileOptions } from "@opnshelf/api"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { ProfileListsPage } from "#/components/profile/ProfileListsPage"; 5 + import { useAuth } from "#/lib/auth-context"; 6 + 7 + export const Route = createFileRoute("/profile/$handle/lists/$listSlug")({ 8 + component: ListDetailPage, 9 + }); 10 + 11 + function ListDetailPage() { 12 + const { handle, listSlug } = Route.useParams(); 13 + const { user } = useAuth(); 14 + 15 + const { data: profile } = useQuery({ 16 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 17 + }); 18 + 19 + const userDid = profile?.did || ""; 20 + const isOwner = user?.did === userDid; 21 + 22 + return ( 23 + <ProfileListsPage 24 + userDid={userDid} 25 + handle={handle} 26 + selectedListSlug={listSlug} 27 + isOwner={isOwner} 28 + /> 29 + ); 30 + }
+30
apps/web/src/routes/profile.$handle/lists.index.tsx
··· 1 + import { usersControllerGetPublicProfileOptions } from "@opnshelf/api"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { ProfileListsPage } from "#/components/profile/ProfileListsPage"; 5 + import { useAuth } from "#/lib/auth-context"; 6 + 7 + export const Route = createFileRoute("/profile/$handle/lists/")({ 8 + component: ListsIndexPage, 9 + }); 10 + 11 + function ListsIndexPage() { 12 + const { handle } = Route.useParams(); 13 + const { user } = useAuth(); 14 + 15 + const { data: profile } = useQuery({ 16 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 17 + }); 18 + 19 + const userDid = profile?.did || ""; 20 + const isOwner = user?.did === userDid; 21 + 22 + return ( 23 + <ProfileListsPage 24 + userDid={userDid} 25 + handle={handle} 26 + selectedListSlug={null} 27 + isOwner={isOwner} 28 + /> 29 + ); 30 + }
+9
apps/web/src/routes/profile.$handle/lists.tsx
··· 1 + import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 + 3 + export const Route = createFileRoute("/profile/$handle/lists")({ 4 + component: ListsLayout, 5 + }); 6 + 7 + function ListsLayout() { 8 + return <Outlet />; 9 + }
+489
apps/web/src/routes/profile.$handle/shelf.tsx
··· 1 + import { 2 + moviesControllerUnmarkWatchedMutation, 3 + shelfControllerGetUserShelfOptions, 4 + shelfControllerGetUserShelfQueryKey, 5 + showsControllerUnmarkWatchedMutation, 6 + usersControllerGetPublicProfileOptions, 7 + } from "@opnshelf/api"; 8 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 + import { createFileRoute, Link } from "@tanstack/react-router"; 10 + import { 11 + Film, 12 + Grid3X3, 13 + List as ListIcon, 14 + Loader2, 15 + Search, 16 + Tv, 17 + X, 18 + } from "lucide-react"; 19 + import { useState } from "react"; 20 + import { Pagination } from "#/components/Pagination"; 21 + import { setupApiClient } from "#/lib/api"; 22 + import { useAuth } from "#/lib/auth-context"; 23 + import { toSlug } from "#/lib/slug"; 24 + 25 + setupApiClient(); 26 + 27 + export const Route = createFileRoute("/profile/$handle/shelf")({ 28 + component: ProfileShelfPage, 29 + }); 30 + 31 + type FilterType = "all" | "movie" | "episode"; 32 + type ViewMode = "grid" | "list"; 33 + 34 + function formatWatchedDate(dateStr?: string): string { 35 + if (!dateStr) return ""; 36 + const date = new Date(dateStr); 37 + return date.toLocaleDateString("en-US", { 38 + month: "short", 39 + day: "numeric", 40 + year: 41 + date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", 42 + }); 43 + } 44 + 45 + function ProfileShelfPage() { 46 + const { handle } = Route.useParams(); 47 + const { user } = useAuth(); 48 + const queryClient = useQueryClient(); 49 + 50 + const { data: profile } = useQuery({ 51 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 52 + }); 53 + const userDid = profile?.did || ""; 54 + const displayName = profile?.displayName || profile?.handle || handle; 55 + const isOwner = user?.did === userDid; 56 + 57 + const [filter, setFilter] = useState<FilterType>("all"); 58 + const [searchQuery, setSearchQuery] = useState(""); 59 + const [viewMode, setViewMode] = useState<ViewMode>("grid"); 60 + const [page, setPage] = useState(1); 61 + 62 + // Server-side pagination with filtering 63 + const { data, isLoading } = useQuery({ 64 + ...shelfControllerGetUserShelfOptions({ 65 + path: { userDid }, 66 + query: { 67 + page, 68 + pageSize: 24, 69 + ...(filter !== "all" ? { type: filter } : {}), 70 + ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), 71 + }, 72 + }), 73 + enabled: !!userDid, 74 + }); 75 + 76 + const handleFilterChange = (newFilter: FilterType) => { 77 + setFilter(newFilter); 78 + setPage(1); 79 + }; 80 + 81 + const handleSearchChange = (value: string) => { 82 + setSearchQuery(value); 83 + setPage(1); 84 + }; 85 + 86 + // Mutations for removing from shelf 87 + const removeMovieMutation = useMutation({ 88 + ...moviesControllerUnmarkWatchedMutation(), 89 + onSuccess: () => { 90 + queryClient.invalidateQueries({ 91 + queryKey: shelfControllerGetUserShelfQueryKey({ path: { userDid } }), 92 + }); 93 + }, 94 + }); 95 + const removeEpisodeMutation = useMutation({ 96 + ...showsControllerUnmarkWatchedMutation(), 97 + onSuccess: () => { 98 + queryClient.invalidateQueries({ 99 + queryKey: shelfControllerGetUserShelfQueryKey({ path: { userDid } }), 100 + }); 101 + }, 102 + }); 103 + 104 + const items = data?.items ?? []; 105 + 106 + return ( 107 + <div className="space-y-6"> 108 + {/* Title & Controls */} 109 + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 110 + <h1 className="text-display-2">{displayName}&apos;s Shelf</h1> 111 + 112 + <div className="flex items-center gap-3"> 113 + {/* Search */} 114 + <div className="relative"> 115 + <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-(--foreground-muted)" /> 116 + <input 117 + type="text" 118 + placeholder="Search shelf..." 119 + className="input h-9 w-48 pl-9! text-sm" 120 + value={searchQuery} 121 + onChange={(e) => handleSearchChange(e.target.value)} 122 + /> 123 + </div> 124 + 125 + {/* View Toggle */} 126 + <div className="flex rounded-lg border border-(--border) bg-(--background-elevated) p-0.5"> 127 + <button 128 + type="button" 129 + onClick={() => setViewMode("grid")} 130 + className={`rounded-md p-1.5 transition-colors ${ 131 + viewMode === "grid" 132 + ? "bg-(--accent) text-[#3f2e00]" 133 + : "text-(--foreground-muted) hover:text-(--foreground)" 134 + }`} 135 + aria-label="Grid view" 136 + > 137 + <Grid3X3 className="h-4 w-4" /> 138 + </button> 139 + <button 140 + type="button" 141 + onClick={() => setViewMode("list")} 142 + className={`rounded-md p-1.5 transition-colors ${ 143 + viewMode === "list" 144 + ? "bg-(--accent) text-[#3f2e00]" 145 + : "text-(--foreground-muted) hover:text-(--foreground)" 146 + }`} 147 + aria-label="List view" 148 + > 149 + <ListIcon className="h-4 w-4" /> 150 + </button> 151 + </div> 152 + </div> 153 + </div> 154 + 155 + {/* Filter Tabs */} 156 + <div className="flex gap-2"> 157 + {( 158 + [ 159 + { key: "all", label: "All", icon: undefined }, 160 + { key: "movie", label: "Movies", icon: Film }, 161 + { key: "episode", label: "TV Episodes", icon: Tv }, 162 + ] as const 163 + ).map((f) => { 164 + const Icon = f.icon; 165 + return ( 166 + <button 167 + key={f.key} 168 + type="button" 169 + onClick={() => handleFilterChange(f.key)} 170 + className={`flex items-center gap-2 rounded-full px-4 py-2 font-medium text-sm transition-colors ${ 171 + filter === f.key 172 + ? "bg-(--accent) text-[#3f2e00]" 173 + : "bg-(--background-elevated) text-(--foreground-muted) hover:bg-(--background-subtle) hover:text-(--foreground)" 174 + }`} 175 + > 176 + {Icon && <Icon className="h-4 w-4" />} 177 + {f.label} 178 + </button> 179 + ); 180 + })} 181 + </div> 182 + 183 + {/* Content */} 184 + {isLoading ? ( 185 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> 186 + {[1, 2, 3, 4, 5, 6].map((i) => ( 187 + <div 188 + key={i} 189 + className="aspect-[2/3] animate-pulse rounded-lg bg-(--background-subtle)" 190 + /> 191 + ))} 192 + </div> 193 + ) : items.length === 0 ? ( 194 + <div className="card p-8 text-center"> 195 + <p className="text-(--foreground-muted)"> 196 + {searchQuery 197 + ? "No results found." 198 + : `${displayName}'s shelf is empty.`} 199 + </p> 200 + </div> 201 + ) : viewMode === "grid" ? ( 202 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> 203 + {items.map((item) => ( 204 + <ShelfGridCard 205 + key={item.id} 206 + item={item} 207 + isOwner={isOwner} 208 + onRemoveMovie={(movieId) => 209 + removeMovieMutation.mutate({ 210 + path: { movieId }, 211 + query: { mode: "all" }, 212 + }) 213 + } 214 + onRemoveEpisode={(showId, seasonNumber, episodeNumber) => 215 + removeEpisodeMutation.mutate({ 216 + path: { showId }, 217 + query: { 218 + seasonNumber, 219 + episodeNumber, 220 + mode: "all", 221 + }, 222 + }) 223 + } 224 + isRemoving={ 225 + removeMovieMutation.isPending || removeEpisodeMutation.isPending 226 + } 227 + /> 228 + ))} 229 + </div> 230 + ) : ( 231 + <div className="space-y-2"> 232 + {items.map((item) => ( 233 + <ShelfListRow 234 + key={item.id} 235 + item={item} 236 + isOwner={isOwner} 237 + onRemoveMovie={(movieId) => 238 + removeMovieMutation.mutate({ 239 + path: { movieId }, 240 + query: { mode: "all" }, 241 + }) 242 + } 243 + onRemoveEpisode={(showId, seasonNumber, episodeNumber) => 244 + removeEpisodeMutation.mutate({ 245 + path: { showId }, 246 + query: { 247 + seasonNumber, 248 + episodeNumber, 249 + mode: "all", 250 + }, 251 + }) 252 + } 253 + isRemoving={ 254 + removeMovieMutation.isPending || removeEpisodeMutation.isPending 255 + } 256 + /> 257 + ))} 258 + </div> 259 + )} 260 + 261 + {/* Pagination */} 262 + {data && data.totalPages > 1 && ( 263 + <div className="flex justify-center pt-4"> 264 + <Pagination 265 + page={data.page} 266 + totalPages={data.totalPages} 267 + onPageChange={setPage} 268 + /> 269 + </div> 270 + )} 271 + </div> 272 + ); 273 + } 274 + 275 + function ShelfGridCard({ 276 + item, 277 + isOwner, 278 + onRemoveMovie, 279 + onRemoveEpisode, 280 + isRemoving, 281 + }: { 282 + item: { 283 + id: string; 284 + type: "movie" | "episode"; 285 + posterPath?: string; 286 + watchedDate?: string; 287 + } & Record<string, unknown>; 288 + isOwner: boolean; 289 + onRemoveMovie: (movieId: string) => void; 290 + onRemoveEpisode: ( 291 + showId: string, 292 + seasonNumber: number, 293 + episodeNumber: number, 294 + ) => void; 295 + isRemoving: boolean; 296 + }) { 297 + const isMovie = item.type === "movie"; 298 + const title = isMovie ? (item.title as string) : (item.showTitle as string); 299 + const id = isMovie ? (item.movieId as string) : (item.showId as string); 300 + const year = isMovie 301 + ? (item.releaseYear as number | undefined) 302 + : (item.firstAirYear as number | undefined); 303 + 304 + const episodeInfo = !isMovie 305 + ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 306 + : undefined; 307 + 308 + const handleRemove = (e: React.MouseEvent) => { 309 + e.preventDefault(); 310 + e.stopPropagation(); 311 + if (isMovie) { 312 + onRemoveMovie(id); 313 + } else { 314 + onRemoveEpisode( 315 + id, 316 + item.seasonNumber as number, 317 + item.episodeNumber as number, 318 + ); 319 + } 320 + }; 321 + 322 + return ( 323 + <div className="group relative"> 324 + <Link 325 + to={ 326 + isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName" 327 + } 328 + params={ 329 + isMovie 330 + ? { movieId: id, movieName: toSlug(title) } 331 + : { showId: id, showName: toSlug(title) } 332 + } 333 + className="block" 334 + > 335 + <div className="aspect-[2/3] overflow-hidden rounded-lg bg-(--background-subtle)"> 336 + {item.posterPath ? ( 337 + <img 338 + src={`https://image.tmdb.org/t/p/w500${item.posterPath}`} 339 + alt={title} 340 + className="h-full w-full object-cover transition-transform group-hover:scale-105" 341 + loading="lazy" 342 + /> 343 + ) : ( 344 + <div className="flex h-full w-full items-center justify-center"> 345 + {isMovie ? ( 346 + <Film className="h-8 w-8 text-(--foreground-muted)" /> 347 + ) : ( 348 + <Tv className="h-8 w-8 text-(--foreground-muted)" /> 349 + )} 350 + </div> 351 + )} 352 + </div> 353 + <div className="mt-2"> 354 + <p className="truncate font-medium text-sm">{title}</p> 355 + <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 356 + {year && <span>{year}</span>} 357 + {episodeInfo && <span>{episodeInfo}</span>} 358 + {item.watchedDate && ( 359 + <span>{formatWatchedDate(item.watchedDate)}</span> 360 + )} 361 + </div> 362 + </div> 363 + </Link> 364 + 365 + {isOwner && ( 366 + <button 367 + type="button" 368 + onClick={handleRemove} 369 + disabled={isRemoving} 370 + className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-red-500 disabled:opacity-100 group-hover:opacity-100" 371 + aria-label="Remove from shelf" 372 + > 373 + {isRemoving ? ( 374 + <Loader2 className="h-3.5 w-3.5 animate-spin" /> 375 + ) : ( 376 + <X className="h-3.5 w-3.5" /> 377 + )} 378 + </button> 379 + )} 380 + </div> 381 + ); 382 + } 383 + 384 + function ShelfListRow({ 385 + item, 386 + isOwner, 387 + onRemoveMovie, 388 + onRemoveEpisode, 389 + isRemoving, 390 + }: { 391 + item: { 392 + id: string; 393 + type: "movie" | "episode"; 394 + posterPath?: string; 395 + watchedDate?: string; 396 + } & Record<string, unknown>; 397 + isOwner: boolean; 398 + onRemoveMovie: (movieId: string) => void; 399 + onRemoveEpisode: ( 400 + showId: string, 401 + seasonNumber: number, 402 + episodeNumber: number, 403 + ) => void; 404 + isRemoving: boolean; 405 + }) { 406 + const isMovie = item.type === "movie"; 407 + const title = isMovie ? (item.title as string) : (item.showTitle as string); 408 + const id = isMovie ? (item.movieId as string) : (item.showId as string); 409 + 410 + const episodeInfo = !isMovie 411 + ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 412 + : undefined; 413 + 414 + const handleRemove = () => { 415 + if (isMovie) { 416 + onRemoveMovie(id); 417 + } else { 418 + onRemoveEpisode( 419 + id, 420 + item.seasonNumber as number, 421 + item.episodeNumber as number, 422 + ); 423 + } 424 + }; 425 + 426 + return ( 427 + <Link 428 + to={isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName"} 429 + params={ 430 + isMovie 431 + ? { movieId: id, movieName: toSlug(title) } 432 + : { showId: id, showName: toSlug(title) } 433 + } 434 + className="card card-interactive flex items-center gap-4 p-3" 435 + > 436 + <div className="h-16 w-11 shrink-0 overflow-hidden rounded-md bg-(--background-subtle)"> 437 + {item.posterPath ? ( 438 + <img 439 + src={`https://image.tmdb.org/t/p/w200${item.posterPath}`} 440 + alt={title} 441 + className="h-full w-full object-cover" 442 + loading="lazy" 443 + /> 444 + ) : ( 445 + <div className="flex h-full w-full items-center justify-center"> 446 + {isMovie ? ( 447 + <Film className="h-4 w-4 text-(--foreground-muted)" /> 448 + ) : ( 449 + <Tv className="h-4 w-4 text-(--foreground-muted)" /> 450 + )} 451 + </div> 452 + )} 453 + </div> 454 + <div className="min-w-0 flex-1"> 455 + <p className="font-medium text-sm">{title}</p> 456 + <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 457 + {episodeInfo && <span>{episodeInfo}</span>} 458 + {item.watchedDate && ( 459 + <span>{formatWatchedDate(item.watchedDate)}</span> 460 + )} 461 + </div> 462 + </div> 463 + <span 464 + className={`badge ${isMovie ? "badge-subtle" : "badge-accent"} text-xs`} 465 + > 466 + {isMovie ? "Movie" : "TV"} 467 + </span> 468 + {isOwner && ( 469 + <button 470 + type="button" 471 + onClick={(e) => { 472 + e.preventDefault(); 473 + e.stopPropagation(); 474 + handleRemove(); 475 + }} 476 + disabled={isRemoving} 477 + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--background-elevated) text-(--foreground-muted) transition-colors hover:border-red-300 hover:bg-red-500/10 hover:text-red-500 disabled:opacity-50" 478 + aria-label="Remove from shelf" 479 + > 480 + {isRemoving ? ( 481 + <Loader2 className="h-4 w-4 animate-spin" /> 482 + ) : ( 483 + <X className="h-4 w-4" /> 484 + )} 485 + </button> 486 + )} 487 + </Link> 488 + ); 489 + }
+242
apps/web/src/routes/profile.$handle/up-next.tsx
··· 1 + import { 2 + showsControllerGetUserUpNextOptions, 3 + usersControllerGetPublicProfileOptions, 4 + } from "@opnshelf/api"; 5 + import { useQuery } from "@tanstack/react-query"; 6 + import { createFileRoute, Link } from "@tanstack/react-router"; 7 + import { Calendar, Check, Loader2, Tv } from "lucide-react"; 8 + import { useState } from "react"; 9 + import { Pagination } from "#/components/Pagination"; 10 + import { setupApiClient } from "#/lib/api"; 11 + import { useAuth } from "#/lib/auth-context"; 12 + import { useMarkEpisodeWatched } from "#/lib/hooks"; 13 + import { toSlug } from "#/lib/slug"; 14 + 15 + setupApiClient(); 16 + 17 + export const Route = createFileRoute("/profile/$handle/up-next")({ 18 + component: ProfileUpNextPage, 19 + }); 20 + 21 + function formatDate(dateStr: string): string { 22 + return new Date(dateStr).toLocaleDateString("en-US", { 23 + month: "short", 24 + day: "numeric", 25 + }); 26 + } 27 + 28 + function formatRelativeDate(dateStr: string): string { 29 + const releaseDate = new Date(dateStr); 30 + const today = new Date(); 31 + today.setHours(0, 0, 0, 0); 32 + releaseDate.setHours(0, 0, 0, 0); 33 + 34 + const diffTime = releaseDate.getTime() - today.getTime(); 35 + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 36 + 37 + if (diffDays === 0) return "Today"; 38 + if (diffDays === 1) return "Tomorrow"; 39 + if (diffDays > 0 && diffDays < 7) return `in ${diffDays} days`; 40 + if (diffDays > 0 && diffDays < 30) 41 + return `in ${Math.ceil(diffDays / 7)} weeks`; 42 + if (diffDays > 0) return `in ${Math.ceil(diffDays / 30)} months`; 43 + if (diffDays === -1) return "Yesterday"; 44 + if (diffDays > -7) return `${Math.abs(diffDays)} days ago`; 45 + if (diffDays > -30) return `${Math.ceil(Math.abs(diffDays) / 7)} weeks ago`; 46 + return `${Math.ceil(Math.abs(diffDays) / 30)} months ago`; 47 + } 48 + 49 + function ProfileUpNextPage() { 50 + const { handle } = Route.useParams(); 51 + const { user } = useAuth(); 52 + const [page, setPage] = useState(1); 53 + 54 + const { data: profile } = useQuery({ 55 + ...usersControllerGetPublicProfileOptions({ path: { handle } }), 56 + }); 57 + const userDid = profile?.did || ""; 58 + const displayName = profile?.displayName || profile?.handle || handle; 59 + const isOwner = user?.did === userDid; 60 + 61 + const { data, isLoading } = useQuery({ 62 + ...showsControllerGetUserUpNextOptions({ 63 + path: { userDid }, 64 + query: { page, pageSize: 20 }, 65 + }), 66 + enabled: !!userDid, 67 + }); 68 + 69 + const markEpisodeMutation = useMarkEpisodeWatched(); 70 + 71 + const items = data?.items ?? []; 72 + 73 + return ( 74 + <div className="space-y-6"> 75 + <h1 className="text-display-2">{displayName}&apos;s Up Next</h1> 76 + 77 + {isLoading ? ( 78 + <div className="space-y-4"> 79 + {[1, 2, 3, 4].map((i) => ( 80 + <div key={i} className="card animate-pulse p-4"> 81 + <div className="flex gap-4"> 82 + <div className="h-24 w-16 rounded-md bg-(--background-subtle)" /> 83 + <div className="flex-1 space-y-2"> 84 + <div className="h-4 w-1/3 rounded bg-(--background-subtle)" /> 85 + <div className="h-3 w-1/4 rounded bg-(--background-subtle)" /> 86 + <div className="h-3 w-1/2 rounded bg-(--background-subtle)" /> 87 + </div> 88 + </div> 89 + </div> 90 + ))} 91 + </div> 92 + ) : items.length === 0 ? ( 93 + <div className="card p-8 text-center"> 94 + <Tv className="mx-auto mb-3 h-12 w-12 text-(--foreground-muted)" /> 95 + <p className="text-(--foreground-muted)"> 96 + {displayName} is all caught up! 97 + </p> 98 + <p className="mt-1 text-(--foreground-muted) text-sm"> 99 + No upcoming episodes to watch. 100 + </p> 101 + </div> 102 + ) : ( 103 + <div className="space-y-4"> 104 + {items.map((item) => { 105 + const show = item.show; 106 + const nextEp = item.nextEpisode; 107 + const progress = 108 + item.totalEpisodes > 0 109 + ? Math.round((item.episodesWatched / item.totalEpisodes) * 100) 110 + : 0; 111 + 112 + return ( 113 + <div 114 + key={`${item.showId}-${nextEp.seasonNumber}-${nextEp.episodeNumber}`} 115 + className="card flex flex-col gap-4 p-4 sm:flex-row" 116 + > 117 + {/* Poster */} 118 + <Link 119 + to="/shows/$showId/$showName" 120 + params={{ 121 + showId: item.showId, 122 + showName: toSlug(show.title), 123 + }} 124 + className="shrink-0" 125 + > 126 + <div className="h-32 w-22 overflow-hidden rounded-lg bg-(--background-subtle) sm:h-36 sm:w-24"> 127 + {show.posterPath ? ( 128 + <img 129 + src={`https://image.tmdb.org/t/p/w500${show.posterPath}`} 130 + alt={show.title} 131 + className="h-full w-full object-cover" 132 + loading="lazy" 133 + /> 134 + ) : ( 135 + <div className="flex h-full w-full items-center justify-center"> 136 + <Tv className="h-8 w-8 text-(--foreground-muted)" /> 137 + </div> 138 + )} 139 + </div> 140 + </Link> 141 + 142 + {/* Info */} 143 + <div className="flex min-w-0 flex-1 flex-col justify-between"> 144 + <div> 145 + <div className="flex items-start justify-between gap-2"> 146 + <div className="min-w-0"> 147 + <Link 148 + to="/shows/$showId/$showName" 149 + params={{ 150 + showId: item.showId, 151 + showName: toSlug(show.title), 152 + }} 153 + className="font-semibold hover:text-(--accent)" 154 + > 155 + {show.title} 156 + </Link> 157 + <p className="mt-0.5 font-medium text-sm"> 158 + {nextEp.name || `Episode ${nextEp.episodeNumber}`} 159 + </p> 160 + </div> 161 + <span className="badge badge-accent shrink-0 text-xs"> 162 + S{nextEp.seasonNumber}E{nextEp.episodeNumber} 163 + </span> 164 + </div> 165 + 166 + {nextEp.airDate && ( 167 + <div className="mt-2 flex items-center gap-2 text-(--foreground-muted) text-sm"> 168 + <Calendar className="h-4 w-4" /> 169 + <span>{formatDate(nextEp.airDate)}</span> 170 + <span className="text-(--accent)"> 171 + • {formatRelativeDate(nextEp.airDate)} 172 + </span> 173 + </div> 174 + )} 175 + 176 + {nextEp.overview && ( 177 + <p className="mt-2 line-clamp-2 text-(--foreground-muted) text-sm"> 178 + {nextEp.overview} 179 + </p> 180 + )} 181 + </div> 182 + 183 + {/* Progress + Action */} 184 + <div className="mt-3 flex items-center gap-4"> 185 + <div className="flex min-w-0 flex-1 items-center gap-2"> 186 + <div className="h-2 flex-1 overflow-hidden rounded-full bg-(--background-subtle)"> 187 + <div 188 + className="h-full rounded-full bg-(--accent) transition-all" 189 + style={{ 190 + width: `${progress}%`, 191 + }} 192 + /> 193 + </div> 194 + <span className="shrink-0 text-(--foreground-muted) text-xs"> 195 + {item.episodesWatched} / {item.totalEpisodes} 196 + </span> 197 + </div> 198 + 199 + {isOwner && ( 200 + <button 201 + type="button" 202 + onClick={() => 203 + markEpisodeMutation.mutate({ 204 + body: { 205 + showId: item.showId, 206 + seasonNumber: nextEp.seasonNumber, 207 + episodeNumber: nextEp.episodeNumber, 208 + }, 209 + }) 210 + } 211 + disabled={markEpisodeMutation.isPending} 212 + className="btn btn-primary gap-2 text-sm" 213 + > 214 + {markEpisodeMutation.isPending ? ( 215 + <Loader2 className="h-4 w-4 animate-spin" /> 216 + ) : ( 217 + <Check className="h-4 w-4" /> 218 + )} 219 + Mark watched 220 + </button> 221 + )} 222 + </div> 223 + </div> 224 + </div> 225 + ); 226 + })} 227 + </div> 228 + )} 229 + 230 + {/* Pagination */} 231 + {data && data.totalPages > 1 && ( 232 + <div className="flex justify-center pt-4"> 233 + <Pagination 234 + page={data.page} 235 + totalPages={data.totalPages} 236 + onPageChange={setPage} 237 + /> 238 + </div> 239 + )} 240 + </div> 241 + ); 242 + }
+536
apps/web/src/routes/settings.tsx
··· 1 + import { 2 + type AccountDeletionJobDto, 3 + authControllerMeOptions, 4 + getAccountDeletionProgress, 5 + getAccountDeletionStatusMessage, 6 + isActiveAccountDeletionStatus, 7 + usersControllerDeleteMyAccountMutation, 8 + usersControllerDeleteMyAvatarMutation, 9 + usersControllerGetMyAccountDeletionOptions, 10 + usersControllerGetMySettingsOptions, 11 + usersControllerUpdateMyProfileMutation, 12 + usersControllerUpdateMySettingsMutation, 13 + usersControllerUploadMyAvatarMutation, 14 + } from "@opnshelf/api"; 15 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 16 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 17 + import { 18 + AlertTriangle, 19 + Camera, 20 + Loader2, 21 + Save, 22 + Settings, 23 + Trash2, 24 + User, 25 + } from "lucide-react"; 26 + import { useEffect, useRef, useState } from "react"; 27 + import TimezoneSelector from "#/components/TimezoneSelector"; 28 + import { Button } from "#/components/ui/button"; 29 + import { 30 + Dialog, 31 + DialogContent, 32 + DialogDescription, 33 + DialogHeader, 34 + DialogTitle, 35 + } from "#/components/ui/dialog"; 36 + import { Switch } from "#/components/ui/switch"; 37 + import { useAuth } from "#/lib/auth-context"; 38 + 39 + function isUnauthorizedError(error: unknown): boolean { 40 + return ( 41 + typeof error === "object" && 42 + error !== null && 43 + ("status" in error || "statusCode" in error) && 44 + ((error as Record<string, unknown>).status === 401 || 45 + (error as Record<string, unknown>).statusCode === 401) 46 + ); 47 + } 48 + 49 + export const Route = createFileRoute("/settings")({ 50 + beforeLoad: async ({ context }) => { 51 + try { 52 + await context.queryClient.fetchQuery(authControllerMeOptions()); 53 + } catch (error) { 54 + if (isUnauthorizedError(error)) { 55 + throw redirect({ 56 + to: "/login", 57 + search: { message: "Please log in to view settings" }, 58 + }); 59 + } 60 + throw error; 61 + } 62 + }, 63 + component: SettingsPage, 64 + }); 65 + 66 + function SettingsPage() { 67 + const { 68 + user, 69 + userSettings, 70 + isAuthenticated, 71 + isLoading: authLoading, 72 + logout, 73 + } = useAuth(); 74 + const navigate = useNavigate(); 75 + const queryClient = useQueryClient(); 76 + 77 + // Redirect if not authenticated 78 + useEffect(() => { 79 + if (!authLoading && !isAuthenticated) { 80 + navigate({ to: "/login" }); 81 + } 82 + }, [authLoading, isAuthenticated, navigate]); 83 + 84 + // Settings mutations 85 + const updateSettingsMutation = useMutation({ 86 + mutationKey: ["users", "me", "settings", "update"], 87 + ...usersControllerUpdateMySettingsMutation(), 88 + onSuccess: () => { 89 + queryClient.invalidateQueries({ 90 + queryKey: usersControllerGetMySettingsOptions().queryKey, 91 + }); 92 + }, 93 + }); 94 + 95 + const updateProfileMutation = useMutation({ 96 + mutationKey: ["users", "me", "profile", "update"], 97 + ...usersControllerUpdateMyProfileMutation(), 98 + onSuccess: () => { 99 + queryClient.invalidateQueries({ 100 + queryKey: authControllerMeOptions().queryKey, 101 + }); 102 + }, 103 + }); 104 + 105 + const uploadAvatarMutation = useMutation({ 106 + mutationKey: ["users", "me", "profile", "avatar", "upload"], 107 + ...usersControllerUploadMyAvatarMutation(), 108 + onSuccess: () => { 109 + queryClient.invalidateQueries({ 110 + queryKey: authControllerMeOptions().queryKey, 111 + }); 112 + }, 113 + }); 114 + 115 + const deleteAvatarMutation = useMutation({ 116 + mutationKey: ["users", "me", "profile", "avatar", "delete"], 117 + ...usersControllerDeleteMyAvatarMutation(), 118 + onSuccess: () => { 119 + queryClient.invalidateQueries({ 120 + queryKey: authControllerMeOptions().queryKey, 121 + }); 122 + }, 123 + }); 124 + 125 + const deleteAccountMutation = useMutation({ 126 + mutationKey: ["users", "me", "account", "delete"], 127 + ...usersControllerDeleteMyAccountMutation(), 128 + }); 129 + 130 + // Display name state 131 + const [displayName, setDisplayName] = useState(user?.displayName ?? ""); 132 + useEffect(() => { 133 + setDisplayName(user?.displayName ?? ""); 134 + }, [user?.displayName]); 135 + 136 + // Avatar file input ref 137 + const fileInputRef = useRef<HTMLInputElement>(null); 138 + 139 + const handleAvatarUpload = (file: File) => { 140 + uploadAvatarMutation.mutate({ 141 + body: { avatar: file }, 142 + }); 143 + }; 144 + 145 + // Deletion dialog state 146 + const [showDeleteDialog, setShowDeleteDialog] = useState(false); 147 + const [confirmChecked, setConfirmChecked] = useState(false); 148 + const [deletePDSData, setDeletePDSData] = useState(false); 149 + const [deletionJob, setDeletionJob] = useState<AccountDeletionJobDto | null>( 150 + null, 151 + ); 152 + 153 + // Poll deletion status when there's an active job 154 + const { data: deletionStatus } = useQuery({ 155 + ...usersControllerGetMyAccountDeletionOptions(), 156 + enabled: !!deletionJob && isActiveAccountDeletionStatus(deletionJob.status), 157 + refetchInterval: 2000, 158 + }); 159 + 160 + useEffect(() => { 161 + if (deletionStatus) { 162 + setDeletionJob(deletionStatus); 163 + if (deletionStatus.status === "completed") { 164 + logout(); 165 + window.location.href = "/"; 166 + } 167 + } 168 + }, [deletionStatus, logout]); 169 + 170 + const handleDeleteAccount = async () => { 171 + try { 172 + const result = await deleteAccountMutation.mutateAsync({ 173 + body: { deletePDSData }, 174 + }); 175 + if (!deletePDSData) { 176 + // Immediate deletion, no job returned 177 + logout(); 178 + window.location.href = "/"; 179 + return; 180 + } 181 + // PDS deletion job started 182 + if (result) { 183 + setDeletionJob(result); 184 + setShowDeleteDialog(false); 185 + } 186 + } catch { 187 + // Error handled by mutation state 188 + } 189 + }; 190 + 191 + const isDeleting = 192 + !!deletionJob && isActiveAccountDeletionStatus(deletionJob.status); 193 + const deletionProgress = deletionJob 194 + ? getAccountDeletionProgress(deletionJob) 195 + : null; 196 + const deletionMessage = deletionJob 197 + ? getAccountDeletionStatusMessage(deletionJob) 198 + : ""; 199 + 200 + if (authLoading) { 201 + return ( 202 + <div className="container-app flex min-h-[50vh] items-center justify-center py-8"> 203 + <Loader2 className="h-8 w-8 animate-spin text-(--accent)" /> 204 + </div> 205 + ); 206 + } 207 + 208 + if (!isAuthenticated || !user) { 209 + return null; 210 + } 211 + 212 + return ( 213 + <div className="container-app max-w-2xl py-8"> 214 + {/* Page Header */} 215 + <div className="mb-8 flex items-center gap-3"> 216 + <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-(--accent-subtle) text-(--accent)"> 217 + <Settings className="h-5 w-5" /> 218 + </div> 219 + <div> 220 + <h1 className="text-display-2">Settings</h1> 221 + <p className="text-(--foreground-muted)"> 222 + Manage your account and preferences 223 + </p> 224 + </div> 225 + </div> 226 + 227 + <div className="space-y-6"> 228 + {/* Time & Region */} 229 + <section className="card p-6"> 230 + <h2 className="mb-1 font-semibold text-lg">Time & Region</h2> 231 + <p className="mb-6 text-(--foreground-muted) text-sm"> 232 + Choose how dates and times are displayed 233 + </p> 234 + 235 + <div className="space-y-5"> 236 + <div className="space-y-2"> 237 + <label htmlFor="timezone" className="font-medium text-sm"> 238 + Timezone 239 + </label> 240 + <TimezoneSelector 241 + value={userSettings?.timezone} 242 + onChange={(timezone) => 243 + updateSettingsMutation.mutate({ 244 + body: { timezone }, 245 + }) 246 + } 247 + disabled={updateSettingsMutation.isPending} 248 + /> 249 + </div> 250 + 251 + <div className="flex items-center justify-between"> 252 + <div> 253 + <label htmlFor="time-format" className="font-medium text-sm"> 254 + 24-hour time 255 + </label> 256 + <p className="text-(--foreground-muted) text-sm"> 257 + Display times in 24-hour format 258 + </p> 259 + </div> 260 + <Switch 261 + id="time-format" 262 + checked={userSettings?.timeFormat === "24h"} 263 + onCheckedChange={(checked) => 264 + updateSettingsMutation.mutate({ 265 + body: { timeFormat: checked ? "24h" : "12h" }, 266 + }) 267 + } 268 + disabled={updateSettingsMutation.isPending} 269 + /> 270 + </div> 271 + </div> 272 + </section> 273 + 274 + {/* Account */} 275 + <section className="card p-6"> 276 + <h2 className="mb-1 font-semibold text-lg">Account</h2> 277 + <p className="mb-6 text-(--foreground-muted) text-sm"> 278 + Update your profile information 279 + </p> 280 + 281 + <div className="space-y-5"> 282 + {/* Avatar */} 283 + <div className="flex items-center gap-4"> 284 + <button 285 + type="button" 286 + onClick={() => fileInputRef.current?.click()} 287 + aria-label="Upload profile photo" 288 + className="group relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-(--border) border-2 bg-(--background-subtle) transition-colors hover:border-(--accent) focus-visible:outline-none focus-visible:ring-(--accent) focus-visible:ring-2" 289 + > 290 + {user.avatar ? ( 291 + <img 292 + src={user.avatar} 293 + alt="" 294 + className="h-full w-full object-cover" 295 + /> 296 + ) : ( 297 + <User className="h-8 w-8 text-(--foreground-muted)" /> 298 + )} 299 + <div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100"> 300 + <Camera className="h-5 w-5 text-white" /> 301 + </div> 302 + {uploadAvatarMutation.isPending && ( 303 + <div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40"> 304 + <Loader2 className="h-5 w-5 animate-spin text-white" /> 305 + </div> 306 + )} 307 + </button> 308 + <input 309 + ref={fileInputRef} 310 + type="file" 311 + accept="image/*" 312 + className="sr-only" 313 + onChange={(e) => { 314 + const file = e.target.files?.[0]; 315 + if (file) handleAvatarUpload(file); 316 + e.target.value = ""; 317 + }} 318 + /> 319 + <div> 320 + <p className="font-medium text-sm">Profile photo</p> 321 + <p className="text-(--foreground-muted) text-sm"> 322 + Click the avatar to upload a new photo 323 + </p> 324 + {user.avatar && ( 325 + <button 326 + type="button" 327 + onClick={() => deleteAvatarMutation.mutate({})} 328 + disabled={deleteAvatarMutation.isPending} 329 + className="mt-1 font-medium text-red-600 text-sm hover:text-red-700 disabled:opacity-50" 330 + > 331 + {deleteAvatarMutation.isPending 332 + ? "Removing…" 333 + : "Remove photo"} 334 + </button> 335 + )} 336 + </div> 337 + </div> 338 + 339 + {/* Display Name */} 340 + <div className="space-y-2"> 341 + <label htmlFor="display-name" className="font-medium text-sm"> 342 + Display name 343 + </label> 344 + <div className="flex gap-2"> 345 + <input 346 + id="display-name" 347 + type="text" 348 + value={displayName} 349 + onChange={(e) => setDisplayName(e.target.value)} 350 + placeholder="Your display name" 351 + className="input flex-1" 352 + /> 353 + <Button 354 + onClick={() => 355 + updateProfileMutation.mutate({ 356 + body: { displayName: displayName || undefined }, 357 + }) 358 + } 359 + disabled={ 360 + updateProfileMutation.isPending || 361 + displayName === (user.displayName ?? "") 362 + } 363 + > 364 + {updateProfileMutation.isPending ? ( 365 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 366 + ) : ( 367 + <Save className="mr-1 h-4 w-4" /> 368 + )} 369 + Save 370 + </Button> 371 + </div> 372 + </div> 373 + 374 + {/* Handle */} 375 + <div className="space-y-2"> 376 + <label htmlFor="handle" className="font-medium text-sm"> 377 + Handle 378 + </label> 379 + <input 380 + id="handle" 381 + type="text" 382 + value={`@${user.handle}`} 383 + disabled 384 + className="input bg-(--background-subtle)" 385 + readOnly 386 + /> 387 + <p className="text-(--foreground-muted) text-xs"> 388 + Your handle is managed by your Bluesky account 389 + </p> 390 + </div> 391 + </div> 392 + </section> 393 + 394 + {/* Account Deletion */} 395 + <section className="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-900 dark:bg-red-950/30"> 396 + <h2 className="mb-1 font-semibold text-lg text-red-900 dark:text-red-100"> 397 + Danger Zone 398 + </h2> 399 + <p className="mb-6 text-red-700 text-sm dark:text-red-300"> 400 + Permanently delete your account and all associated data 401 + </p> 402 + 403 + {isDeleting && deletionJob ? ( 404 + <div className="space-y-3"> 405 + <div className="flex items-center gap-2 text-red-800 dark:text-red-200"> 406 + <Loader2 className="h-4 w-4 animate-spin" /> 407 + <span className="font-medium text-sm">{deletionMessage}</span> 408 + </div> 409 + {deletionProgress !== null && ( 410 + <div className="h-2 w-full overflow-hidden rounded-full bg-red-200 dark:bg-red-900"> 411 + <div 412 + className="h-full rounded-full bg-red-600 transition-all dark:bg-red-400" 413 + style={{ width: `${deletionProgress}%` }} 414 + /> 415 + </div> 416 + )} 417 + {deletionJob.status === "failed" && ( 418 + <div className="space-y-2"> 419 + <p className="text-red-700 text-sm dark:text-red-300"> 420 + {deletionJob.lastError} 421 + </p> 422 + <Button 423 + variant="outline" 424 + onClick={handleDeleteAccount} 425 + disabled={deleteAccountMutation.isPending} 426 + className="border-red-300 text-red-700 hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900" 427 + > 428 + {deleteAccountMutation.isPending ? ( 429 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 430 + ) : null} 431 + Retry 432 + </Button> 433 + </div> 434 + )} 435 + </div> 436 + ) : ( 437 + <button 438 + type="button" 439 + onClick={() => { 440 + setConfirmChecked(false); 441 + setDeletePDSData(false); 442 + setShowDeleteDialog(true); 443 + }} 444 + className="btn inline-flex items-center gap-2 border-red-300 bg-red-100 text-red-700 hover:bg-red-200 dark:border-red-800 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/60" 445 + > 446 + <Trash2 className="h-4 w-4" /> 447 + Delete Account 448 + </button> 449 + )} 450 + </section> 451 + </div> 452 + 453 + {/* Delete Account Dialog */} 454 + <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> 455 + <DialogContent className="sm:max-w-[425px]"> 456 + <DialogHeader> 457 + <DialogTitle className="flex items-center gap-2 text-red-700 dark:text-red-300"> 458 + <AlertTriangle className="h-5 w-5" /> 459 + Delete your account? 460 + </DialogTitle> 461 + <DialogDescription> 462 + This action cannot be undone. All your data will be permanently 463 + removed. 464 + </DialogDescription> 465 + </DialogHeader> 466 + 467 + <div className="space-y-4 py-4"> 468 + <div className="flex items-start gap-3 rounded-lg border border-(--border) p-3"> 469 + <input 470 + type="checkbox" 471 + id="confirm-delete" 472 + checked={confirmChecked} 473 + onChange={(e) => setConfirmChecked(e.target.checked)} 474 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-(--border) accent-red-600" 475 + /> 476 + <label 477 + htmlFor="confirm-delete" 478 + className="text-sm leading-relaxed" 479 + > 480 + I understand that deleting my account is permanent and cannot be 481 + undone. 482 + </label> 483 + </div> 484 + 485 + <div className="flex items-start gap-3 rounded-lg border border-(--border) p-3"> 486 + <input 487 + type="checkbox" 488 + id="delete-pds" 489 + checked={deletePDSData} 490 + onChange={(e) => setDeletePDSData(e.target.checked)} 491 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-(--border) accent-red-600" 492 + /> 493 + <label htmlFor="delete-pds" className="text-sm leading-relaxed"> 494 + Also delete my OpnShelf data from my PDS, including watch 495 + history, follows, lists, and list items. 496 + </label> 497 + </div> 498 + 499 + {deleteAccountMutation.isError && ( 500 + <div className="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-red-700 text-sm dark:bg-red-950/50 dark:text-red-300"> 501 + <AlertTriangle className="h-4 w-4 shrink-0" /> 502 + <span> 503 + {deleteAccountMutation.error instanceof Error 504 + ? deleteAccountMutation.error.message 505 + : "Failed to delete account. Please try again."} 506 + </span> 507 + </div> 508 + )} 509 + </div> 510 + 511 + <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> 512 + <Button 513 + variant="outline" 514 + onClick={() => setShowDeleteDialog(false)} 515 + > 516 + Cancel 517 + </Button> 518 + <Button 519 + variant="destructive" 520 + onClick={handleDeleteAccount} 521 + disabled={!confirmChecked || deleteAccountMutation.isPending} 522 + className="bg-red-600 hover:bg-red-700" 523 + > 524 + {deleteAccountMutation.isPending ? ( 525 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 526 + ) : ( 527 + <Trash2 className="mr-1 h-4 w-4" /> 528 + )} 529 + Permanently Delete Account 530 + </Button> 531 + </div> 532 + </DialogContent> 533 + </Dialog> 534 + </div> 535 + ); 536 + }
+8 -81
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 38 38 import MediaActionsBar from "../../../../components/MediaActionsBar"; 39 39 import MediaHero from "../../../../components/MediaHero"; 40 40 import PersonGrid from "../../../../components/PersonGrid"; 41 + import { YourActivity } from "../../../../components/YourActivity"; 41 42 42 43 setupApiClient(); 43 44 ··· 350 351 {/* Right Column - Sidebar */} 351 352 <div className="space-y-6"> 352 353 {/* Your Activity */} 353 - <section className="card p-5"> 354 - <h3 className="mb-4 font-display font-semibold">Your Activity</h3> 355 - {episodeWatchHistory.length > 0 ? ( 356 - <div className="space-y-1"> 357 - {episodeWatchHistory.map((entry, index) => ( 358 - <div 359 - key={entry.id || index} 360 - className="group flex items-center rounded-lg transition-colors hover:bg-(--background-subtle)" 361 - > 362 - <div className="flex flex-1 items-center p-2"> 363 - <span className="font-medium text-sm"> 364 - {entry.watchedDate 365 - ? new Date(entry.watchedDate).toLocaleString( 366 - "en-US", 367 - { 368 - month: "short", 369 - day: "numeric", 370 - year: "numeric", 371 - hour: "numeric", 372 - minute: "2-digit", 373 - }, 374 - ) 375 - : "Unknown"} 376 - </span> 377 - </div> 378 - <button 379 - type="button" 380 - onClick={() => deleteEpisodeWatchHistoryEntry(entry.id)} 381 - disabled={isDeleteEpisodeHistoryPending} 382 - className="flex h-8 w-8 items-center justify-center rounded-md text-(--foreground-muted) transition-colors hover:bg-red-500/10 hover:text-red-500" 383 - aria-label="Remove this play" 384 - > 385 - <X className="h-4 w-4" /> 386 - </button> 387 - </div> 388 - ))} 389 - <button 390 - type="button" 391 - onClick={() => markEpisodeWatched(seasonNum, episodeNum)} 392 - disabled={isMarkEpisodePending} 393 - className="btn btn-secondary mt-3 w-full gap-2" 394 - > 395 - {isMarkEpisodePending ? ( 396 - <> 397 - <Loader2 className="h-4 w-4 animate-spin" /> 398 - Loading 399 - </> 400 - ) : ( 401 - <> 402 - <Plus className="h-4 w-4" /> 403 - Add to shelf 404 - </> 405 - )} 406 - </button> 407 - </div> 408 - ) : ( 409 - <div className="space-y-3"> 410 - <p className="text-(--foreground-muted) text-sm"> 411 - You haven&apos;t watched this yet 412 - </p> 413 - <button 414 - type="button" 415 - onClick={() => markEpisodeWatched(seasonNum, episodeNum)} 416 - disabled={isMarkEpisodePending} 417 - className="btn btn-secondary w-full gap-2 text-sm" 418 - > 419 - {isMarkEpisodePending ? ( 420 - <> 421 - <Loader2 className="h-4 w-4 animate-spin" /> 422 - Loading 423 - </> 424 - ) : ( 425 - <> 426 - <Plus className="h-4 w-4" /> 427 - Add to shelf 428 - </> 429 - )} 430 - </button> 431 - </div> 432 - )} 433 - </section> 354 + <YourActivity 355 + watchHistory={episodeWatchHistory} 356 + onAddToShelf={() => markEpisodeWatched(seasonNum, episodeNum)} 357 + onDeleteEntry={deleteEpisodeWatchHistoryEntry} 358 + isAddPending={isMarkEpisodePending} 359 + isDeletePending={isDeleteEpisodeHistoryPending} 360 + /> 434 361 435 362 {/* Details */} 436 363 <DetailsCard
+2
backend/src/shelf/shelf.controller.ts
··· 27 27 userDid, 28 28 page, 29 29 pageSize, 30 + query.type, 31 + query.search, 30 32 ); 31 33 32 34 // Transform items to DTO format
+16 -1
backend/src/shelf/shelf.dto.ts
··· 1 1 import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 2 2 import { Type } from "class-transformer"; 3 - import { IsInt, IsOptional, Max, Min } from "class-validator"; 3 + import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; 4 4 import { MovieColorsDto } from "../movies/dto/movie.dto"; 5 5 6 6 export class ShelfItemMovieDto { ··· 150 150 @Min(1) 151 151 @Max(50) 152 152 pageSize?: number; 153 + 154 + @ApiPropertyOptional({ 155 + description: "Filter by item type", 156 + enum: ["movie", "episode"], 157 + }) 158 + @IsOptional() 159 + @IsString() 160 + type?: "movie" | "episode"; 161 + 162 + @ApiPropertyOptional({ 163 + description: "Search by title (case-insensitive partial match)", 164 + }) 165 + @IsOptional() 166 + @IsString() 167 + search?: string; 153 168 } 154 169 155 170 export class ShelfResponseDto {
+130 -62
backend/src/shelf/shelf.service.ts
··· 87 87 userDid: string, 88 88 page: number = 1, 89 89 pageSize: number = 20, 90 + type?: "movie" | "episode", 91 + search?: string, 90 92 ): Promise<{ 91 93 items: ShelfItem[]; 92 94 total: number; ··· 98 100 }> { 99 101 const safePageSize = Math.min(Math.max(pageSize, 1), 50); 100 102 const requestedPage = Math.max(page, 1); 103 + const searchTerm = search?.trim(); 101 104 102 - const [trackedMovieCount, trackedEpisodeCount] = await Promise.all([ 103 - this.prisma.trackedMovie.count({ where: { userDid } }), 104 - this.prisma.trackedEpisode.count({ where: { userDid } }), 105 - ]); 105 + // Build count queries conditionally based on type filter 106 + const countPromises: Promise<number>[] = []; 107 + if (!type || type === "movie") { 108 + countPromises.push( 109 + this.prisma.trackedMovie.count({ 110 + where: { 111 + userDid, 112 + movie: searchTerm 113 + ? { 114 + title: { 115 + contains: searchTerm, 116 + mode: "insensitive", 117 + }, 118 + } 119 + : undefined, 120 + }, 121 + }), 122 + ); 123 + } else { 124 + countPromises.push(Promise.resolve(0)); 125 + } 126 + if (!type || type === "episode") { 127 + countPromises.push( 128 + this.prisma.trackedEpisode.count({ 129 + where: { 130 + userDid, 131 + show: searchTerm 132 + ? { 133 + title: { 134 + contains: searchTerm, 135 + mode: "insensitive", 136 + }, 137 + } 138 + : undefined, 139 + }, 140 + }), 141 + ); 142 + } else { 143 + countPromises.push(Promise.resolve(0)); 144 + } 145 + 146 + const [trackedMovieCount, trackedEpisodeCount] = 147 + await Promise.all(countPromises); 106 148 107 149 const total = trackedMovieCount + trackedEpisodeCount; 108 150 const totalPages = total > 0 ? Math.ceil(total / safePageSize) : 0; ··· 110 152 totalPages > 0 ? Math.min(requestedPage, totalPages) : 1; 111 153 const offset = (currentPage - 1) * safePageSize; 112 154 155 + // Build search conditions for raw SQL 156 + const movieSearchCondition = searchTerm 157 + ? Prisma.sql`AND m.title ILIKE ${`%${searchTerm}%`}` 158 + : Prisma.empty; 159 + const episodeSearchCondition = searchTerm 160 + ? Prisma.sql`AND s.title ILIKE ${`%${searchTerm}%`}` 161 + : Prisma.empty; 162 + 163 + // Build the UNION query conditionally based on type filter 164 + const movieQuery = Prisma.sql` 165 + SELECT 166 + 'movie:' || tm.id AS "trackedId", 167 + 'movie' AS "type", 168 + tm."watchedDate" AS "watchedDate", 169 + tm."createdAt" AS "createdAt", 170 + COALESCE(tm."watchedDate", tm."createdAt") AS "sortDate", 171 + tm."movieId" AS "movieId", 172 + NULL::text AS "showId", 173 + m.title AS "title", 174 + m."posterPath" AS "posterPath", 175 + m."backdropPath" AS "backdropPath", 176 + m."releaseYear" AS "releaseYear", 177 + m."releaseDate" AS "releaseDate", 178 + NULL::integer AS "seasonNumber", 179 + NULL::integer AS "episodeNumber", 180 + NULL::text AS "episodeName", 181 + NULL::integer AS "firstAirYear", 182 + NULL::timestamp AS "firstAirDate", 183 + m.overview AS "overview" 184 + FROM "TrackedMovie" tm 185 + INNER JOIN "Movie" m ON m."movieId" = tm."movieId" 186 + WHERE tm."userDid" = ${userDid} 187 + ${movieSearchCondition} 188 + `; 189 + 190 + const episodeQuery = Prisma.sql` 191 + SELECT 192 + 'episode:' || te.id AS "trackedId", 193 + 'episode' AS "type", 194 + te."watchedDate" AS "watchedDate", 195 + te."createdAt" AS "createdAt", 196 + COALESCE(te."watchedDate", te."createdAt") AS "sortDate", 197 + NULL::text AS "movieId", 198 + te."showId" AS "showId", 199 + s.title AS "title", 200 + s."posterPath" AS "posterPath", 201 + s."backdropPath" AS "backdropPath", 202 + NULL::integer AS "releaseYear", 203 + NULL::timestamp AS "releaseDate", 204 + te."seasonNumber" AS "seasonNumber", 205 + te."episodeNumber" AS "episodeNumber", 206 + ep.name AS "episodeName", 207 + s."firstAirYear" AS "firstAirYear", 208 + s."firstAirDate" AS "firstAirDate", 209 + ep.overview AS "overview" 210 + FROM "TrackedEpisode" te 211 + INNER JOIN "Show" s ON s."showId" = te."showId" 212 + LEFT JOIN "Episode" ep ON ep."showId" = te."showId" 213 + AND ep."seasonNumber" = te."seasonNumber" 214 + AND ep."episodeNumber" = te."episodeNumber" 215 + WHERE te."userDid" = ${userDid} 216 + ${episodeSearchCondition} 217 + `; 218 + 219 + let shelfQuery: Prisma.Sql; 220 + if (type === "movie") { 221 + shelfQuery = Prisma.sql`(${movieQuery})`; 222 + } else if (type === "episode") { 223 + shelfQuery = Prisma.sql`(${episodeQuery})`; 224 + } else { 225 + shelfQuery = Prisma.sql`( 226 + ${movieQuery} 227 + UNION ALL 228 + ${episodeQuery} 229 + )`; 230 + } 231 + 113 232 const rows = 114 233 total === 0 115 234 ? [] ··· 126 245 shelf."backdropPath", 127 246 shelf."releaseYear", 128 247 shelf."releaseDate", 129 - shelf."seasonNumber", 130 - shelf."episodeNumber", 131 - shelf."episodeName", 132 - shelf."firstAirYear", 133 - shelf."firstAirDate", 134 - shelf."overview" 135 - FROM ( 136 - SELECT 137 - 'movie:' || tm.id AS "trackedId", 138 - 'movie' AS "type", 139 - tm."watchedDate" AS "watchedDate", 140 - tm."createdAt" AS "createdAt", 141 - COALESCE(tm."watchedDate", tm."createdAt") AS "sortDate", 142 - tm."movieId" AS "movieId", 143 - NULL::text AS "showId", 144 - m.title AS "title", 145 - m."posterPath" AS "posterPath", 146 - m."backdropPath" AS "backdropPath", 147 - m."releaseYear" AS "releaseYear", 148 - m."releaseDate" AS "releaseDate", 149 - NULL::integer AS "seasonNumber", 150 - NULL::integer AS "episodeNumber", 151 - NULL::text AS "episodeName", 152 - NULL::integer AS "firstAirYear", 153 - NULL::timestamp AS "firstAirDate", 154 - m.overview AS "overview" 155 - FROM "TrackedMovie" tm 156 - INNER JOIN "Movie" m ON m."movieId" = tm."movieId" 157 - WHERE tm."userDid" = ${userDid} 158 - 159 - UNION ALL 160 - 161 - SELECT 162 - 'episode:' || te.id AS "trackedId", 163 - 'episode' AS "type", 164 - te."watchedDate" AS "watchedDate", 165 - te."createdAt" AS "createdAt", 166 - COALESCE(te."watchedDate", te."createdAt") AS "sortDate", 167 - NULL::text AS "movieId", 168 - te."showId" AS "showId", 169 - s.title AS "title", 170 - s."posterPath" AS "posterPath", 171 - s."backdropPath" AS "backdropPath", 172 - NULL::integer AS "releaseYear", 173 - NULL::timestamp AS "releaseDate", 174 - te."seasonNumber" AS "seasonNumber", 175 - te."episodeNumber" AS "episodeNumber", 176 - ep.name AS "episodeName", 177 - s."firstAirYear" AS "firstAirYear", 178 - s."firstAirDate" AS "firstAirDate", 179 - ep.overview AS "overview" 180 - FROM "TrackedEpisode" te 181 - INNER JOIN "Show" s ON s."showId" = te."showId" 182 - LEFT JOIN "Episode" ep ON ep."showId" = te."showId" 183 - AND ep."seasonNumber" = te."seasonNumber" 184 - AND ep."episodeNumber" = te."episodeNumber" 185 - WHERE te."userDid" = ${userDid} 186 - ) shelf 248 + shelf."seasonNumber", 249 + shelf."episodeNumber", 250 + shelf."episodeName", 251 + shelf."firstAirYear", 252 + shelf."firstAirDate", 253 + shelf."overview" 254 + FROM ${shelfQuery} shelf 187 255 ORDER BY 188 256 shelf."sortDate" DESC, 189 257 shelf."createdAt" DESC,
+30 -25
backend/src/social/social.service.ts
··· 255 255 } 256 256 257 257 async getFollowers( 258 - viewerDid: string, 258 + viewerDid: string | null, 259 259 handle: string, 260 260 page = 1, 261 261 pageSize = DEFAULT_SOCIAL_PAGE_SIZE, ··· 293 293 } 294 294 295 295 async getFollowing( 296 - viewerDid: string, 296 + viewerDid: string | null, 297 297 handle: string, 298 298 page = 1, 299 299 pageSize = DEFAULT_SOCIAL_PAGE_SIZE, ··· 659 659 660 660 private async buildSocialUserCards( 661 661 userDids: string[], 662 - viewerDid: string, 662 + viewerDid: string | null, 663 663 baseUsers?: Map<string, SocialUserRecord>, 664 664 ): Promise<Map<string, SocialUserCardDto>> { 665 665 const uniqueUserDids = [...new Set(userDids)]; ··· 678 678 ).map((user) => [user.did, user]), 679 679 ); 680 680 681 - const [viewerFollowing, viewerFollowers] = await Promise.all([ 682 - this.prisma.follow.findMany({ 683 - where: { 684 - followerDid: viewerDid, 685 - followingDid: { in: uniqueUserDids }, 686 - }, 687 - select: { followingDid: true }, 688 - }), 689 - this.prisma.follow.findMany({ 690 - where: { 691 - followingDid: viewerDid, 692 - followerDid: { in: uniqueUserDids }, 693 - }, 694 - select: { followerDid: true }, 695 - }), 696 - ]); 681 + let followingSet = new Set<string>(); 682 + let followerSet = new Set<string>(); 683 + 684 + if (viewerDid) { 685 + const [viewerFollowing, viewerFollowers] = await Promise.all([ 686 + this.prisma.follow.findMany({ 687 + where: { 688 + followerDid: viewerDid, 689 + followingDid: { in: uniqueUserDids }, 690 + }, 691 + select: { followingDid: true }, 692 + }), 693 + this.prisma.follow.findMany({ 694 + where: { 695 + followingDid: viewerDid, 696 + followerDid: { in: uniqueUserDids }, 697 + }, 698 + select: { followerDid: true }, 699 + }), 700 + ]); 697 701 698 - const followingSet = new Set( 699 - viewerFollowing.map((follow) => follow.followingDid), 700 - ); 701 - const followerSet = new Set( 702 - viewerFollowers.map((follow) => follow.followerDid), 703 - ); 702 + followingSet = new Set( 703 + viewerFollowing.map((follow) => follow.followingDid), 704 + ); 705 + followerSet = new Set( 706 + viewerFollowers.map((follow) => follow.followerDid), 707 + ); 708 + } 704 709 705 710 return new Map( 706 711 uniqueUserDids
+46 -2
backend/src/users/users.controller.ts
··· 25 25 ApiTags, 26 26 } from "@nestjs/swagger"; 27 27 import { FileInterceptor } from "@nestjs/platform-express"; 28 - import type { Response } from "express"; 28 + import type { Request, Response } from "express"; 29 29 import { AuthGuard } from "../auth/auth.guard"; 30 30 import type { AuthenticatedRequest } from "../auth/types"; 31 31 import { ··· 50 50 } from "./dto/user-settings.dto"; 51 51 import { parseAccountDeletionData } from "./background-job-data"; 52 52 import { UsersService } from "./users.service"; 53 + import { SocialService } from "../social/social.service"; 53 54 import type { ATSession } from "../movies/movies.service"; 55 + import { 56 + SocialPaginationQueryDto, 57 + PaginatedSocialUsersDto, 58 + } from "../social/dto/social.dto"; 54 59 55 60 @ApiTags("users") 56 61 @Controller("users") 57 62 export class UsersController { 58 - constructor(private readonly usersService: UsersService) {} 63 + constructor( 64 + private readonly usersService: UsersService, 65 + private readonly socialService: SocialService, 66 + ) {} 59 67 60 68 @Get(":handle/profile") 61 69 @ApiOperation({ summary: "Get a public user profile by handle" }) ··· 65 73 @Param("handle") handle: string, 66 74 ): Promise<PublicUserProfileDto> { 67 75 return this.usersService.getPublicProfileByHandle(handle); 76 + } 77 + 78 + @Get(":handle/followers") 79 + @ApiOperation({ summary: "Get public followers for a user by handle" }) 80 + @ApiResponse({ status: 200, type: PaginatedSocialUsersDto }) 81 + @ApiResponse({ status: 404, description: "User not found" }) 82 + async getPublicFollowers( 83 + @Param("handle") handle: string, 84 + @Query() query: SocialPaginationQueryDto, 85 + @Req() req: Request, 86 + ): Promise<PaginatedSocialUsersDto> { 87 + const viewerDid = (req as AuthenticatedRequest).user?.did ?? null; 88 + return this.socialService.getFollowers( 89 + viewerDid, 90 + handle, 91 + query.page ?? 1, 92 + query.pageSize ?? 20, 93 + ); 94 + } 95 + 96 + @Get(":handle/following") 97 + @ApiOperation({ summary: "Get public following for a user by handle" }) 98 + @ApiResponse({ status: 200, type: PaginatedSocialUsersDto }) 99 + @ApiResponse({ status: 404, description: "User not found" }) 100 + async getPublicFollowing( 101 + @Param("handle") handle: string, 102 + @Query() query: SocialPaginationQueryDto, 103 + @Req() req: Request, 104 + ): Promise<PaginatedSocialUsersDto> { 105 + const viewerDid = (req as AuthenticatedRequest).user?.did ?? null; 106 + return this.socialService.getFollowing( 107 + viewerDid, 108 + handle, 109 + query.page ?? 1, 110 + query.pageSize ?? 20, 111 + ); 68 112 } 69 113 70 114 @Get("avatar")
+2
backend/src/users/users.module.ts
··· 5 5 import { MoviesModule } from "../movies/movies.module"; 6 6 import { PrismaModule } from "../prisma/prisma.module"; 7 7 import { ShowsModule } from "../shows/shows.module"; 8 + import { SocialModule } from "../social/social.module"; 8 9 import { BackgroundJobWorkerService } from "./background-job-worker.service"; 9 10 import { ImportHistoryService } from "./import-history.service"; 10 11 import { ProfileService } from "./profile.service"; ··· 19 20 ListsModule, 20 21 MoviesModule, 21 22 ShowsModule, 23 + SocialModule, 22 24 forwardRef(() => AuthModule), 23 25 ], 24 26 controllers: [UsersController],
+11
skills-lock.json
··· 1 + { 2 + "version": 1, 3 + "skills": { 4 + "shadcn": { 5 + "source": "shadcn/ui", 6 + "sourceType": "github", 7 + "skillPath": "skills/shadcn/SKILL.md", 8 + "computedHash": "80a6226e78f6d1fe464214ae0ef449d49d8ffaa3e7704f011e9b418c678ad4d1" 9 + } 10 + } 11 + }