瀏覽代碼

feat: add landing page, dice roller, and Sentry/error state components

Combines work from three feature branches:
- Landing page with reel animation, genre roll, and privacy policy
- Dice roller system with genre roll filtering
- Sentry integration and shared UI state components (error boundary, loading spinner, offline banner)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 月之前
父節點
當前提交
c62a4b59d3
共有 86 個文件被更改,包括 6692 次插入1 次删除
  1. 1 0
      .gitignore
  2. 7 1
      next.config.ts
  3. 17 0
      sentry.client.config.ts
  4. 17 0
      sentry.edge.config.ts
  5. 17 0
      sentry.server.config.ts
  6. 18 0
      src/app/(app)/create-group/page.tsx
  7. 41 0
      src/app/(app)/home/page.tsx
  8. 17 0
      src/app/(app)/join/page.tsx
  9. 16 0
      src/app/(app)/layout.tsx
  10. 3 0
      src/app/(public)/layout.tsx
  11. 34 0
      src/app/(public)/page.tsx
  12. 226 0
      src/app/(public)/privacy/page.tsx
  13. 3 0
      src/app/admin/layout.tsx
  14. 90 0
      src/app/admin/login/page.tsx
  15. 21 0
      src/app/admin/logout-button.tsx
  16. 23 0
      src/app/admin/page.tsx
  17. 29 0
      src/app/api/admin/groups/[id]/route.ts
  18. 38 0
      src/app/api/admin/groups/route.ts
  19. 38 0
      src/app/api/admin/login/route.ts
  20. 8 0
      src/app/api/admin/logout/route.ts
  21. 35 0
      src/app/api/admin/users/[id]/route.ts
  22. 38 0
      src/app/api/admin/users/route.ts
  23. 49 0
      src/app/api/groups/[id]/invite/route.ts
  24. 66 0
      src/app/api/groups/[id]/leave/route.ts
  25. 132 0
      src/app/api/groups/[id]/members/route.ts
  26. 134 0
      src/app/api/groups/[id]/route.ts
  27. 88 0
      src/app/api/groups/[id]/transfer/route.ts
  28. 91 0
      src/app/api/groups/join/route.ts
  29. 61 0
      src/app/api/groups/route.ts
  30. 51 0
      src/app/api/movies/[id]/_helpers.ts
  31. 55 0
      src/app/api/movies/[id]/route.ts
  32. 39 0
      src/app/api/movies/[id]/watched/route.ts
  33. 31 0
      src/app/api/tmdb/reel-posters/route.ts
  34. 79 0
      src/components/admin/group-card.tsx
  35. 124 0
      src/components/admin/search-panel.tsx
  36. 86 0
      src/components/admin/user-card.tsx
  37. 44 0
      src/components/dice/genre-roll-input.tsx
  38. 33 0
      src/components/dice/movie-card-content.tsx
  39. 122 0
      src/components/dice/roll-animation.tsx
  40. 25 0
      src/components/dice/roll-button.tsx
  41. 34 0
      src/components/dice/roll-result.tsx
  42. 16 0
      src/components/dice/teaser-card.tsx
  43. 82 0
      src/components/groups/create-group-form.tsx
  44. 79 0
      src/components/groups/join-form.tsx
  45. 111 0
      src/components/groups/member-list.tsx
  46. 240 0
      src/components/groups/settings-panel.tsx
  47. 81 0
      src/components/groups/transfer-ownership-modal.tsx
  48. 29 0
      src/components/home/empty-state.tsx
  49. 26 0
      src/components/home/list-card.tsx
  50. 39 0
      src/components/home/list-grid.tsx
  51. 17 0
      src/components/landing/about-section.tsx
  52. 119 0
      src/components/landing/genre-roll-landing.tsx
  53. 10 0
      src/components/landing/hero.tsx
  54. 45 0
      src/components/landing/how-it-works.tsx
  55. 244 0
      src/components/landing/reel-animation.tsx
  56. 49 0
      src/components/landing/teaser-card.tsx
  57. 49 0
      src/components/movies/delete-button.tsx
  58. 151 0
      src/components/movies/expanded-panel.tsx
  59. 23 0
      src/components/movies/genre-tag.tsx
  60. 20 0
      src/components/movies/trailer-button.tsx
  61. 34 0
      src/components/movies/watched-button.tsx
  62. 38 0
      src/components/shared/empty-state.tsx
  63. 63 0
      src/components/shared/error-boundary.tsx
  64. 32 0
      src/components/shared/error-message.tsx
  65. 18 0
      src/components/shared/loading-spinner.tsx
  66. 45 0
      src/components/shared/offline-banner.tsx
  67. 33 0
      src/components/shared/tmdb-footer.tsx
  68. 2428 0
      src/data/word-list.ts
  69. 27 0
      src/hooks/use-delete-movie.ts
  70. 17 0
      src/hooks/use-genre-filter.ts
  71. 49 0
      src/hooks/use-roll.ts
  72. 34 0
      src/hooks/use-toggle-watched.ts
  73. 73 0
      src/hooks/use-user-groups.ts
  74. 9 0
      src/instrumentation.ts
  75. 22 0
      src/lib/admin/session.ts
  76. 7 0
      src/lib/admin/totp.ts
  77. 2 0
      src/lib/constants.ts
  78. 59 0
      src/lib/dice/genre-filter.ts
  79. 16 0
      src/lib/dice/randomizer.ts
  80. 14 0
      src/lib/groups/delete-group.ts
  81. 33 0
      src/lib/groups/invite-code.ts
  82. 68 0
      src/lib/groups/rate-limit.ts
  83. 8 0
      src/lib/groups/validation.ts
  84. 25 0
      src/lib/sentry-utils.ts
  85. 24 0
      src/lib/tmdb-fetch.ts
  86. 3 0
      src/types/movie.ts

+ 1 - 0
.gitignore

@@ -42,3 +42,4 @@ supabase/.temp/
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+.claude/

+ 7 - 1
next.config.ts

@@ -1,3 +1,4 @@
+import { withSentryConfig } from "@sentry/nextjs";
 import type { NextConfig } from "next";
 
 const nextConfig: NextConfig = {
@@ -13,4 +14,9 @@ const nextConfig: NextConfig = {
   },
 };
 
-export default nextConfig;
+export default withSentryConfig(nextConfig, {
+  silent: true,
+  sourcemaps: {
+    disable: !process.env.SENTRY_AUTH_TOKEN,
+  },
+});

+ 17 - 0
sentry.client.config.ts

@@ -0,0 +1,17 @@
+import * as Sentry from "@sentry/nextjs";
+import { stripUuids } from "@/lib/sentry-utils";
+
+const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
+
+if (dsn) {
+  Sentry.init({
+    dsn,
+    tracesSampleRate: 0.1,
+    beforeSend(event) {
+      return stripUuids(event);
+    },
+    beforeSendTransaction(event) {
+      return stripUuids(event);
+    },
+  });
+}

+ 17 - 0
sentry.edge.config.ts

@@ -0,0 +1,17 @@
+import * as Sentry from "@sentry/nextjs";
+import { stripUuids } from "@/lib/sentry-utils";
+
+const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
+
+if (dsn) {
+  Sentry.init({
+    dsn,
+    tracesSampleRate: 0.1,
+    beforeSend(event) {
+      return stripUuids(event);
+    },
+    beforeSendTransaction(event) {
+      return stripUuids(event);
+    },
+  });
+}

+ 17 - 0
sentry.server.config.ts

@@ -0,0 +1,17 @@
+import * as Sentry from "@sentry/nextjs";
+import { stripUuids } from "@/lib/sentry-utils";
+
+const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
+
+if (dsn) {
+  Sentry.init({
+    dsn,
+    tracesSampleRate: 0.1,
+    beforeSend(event) {
+      return stripUuids(event);
+    },
+    beforeSendTransaction(event) {
+      return stripUuids(event);
+    },
+  });
+}

+ 18 - 0
src/app/(app)/create-group/page.tsx

@@ -0,0 +1,18 @@
+import { CreateGroupForm } from "@/components/groups/create-group-form";
+
+export const metadata = {
+  title: "Create Group - MovieDice",
+};
+
+export default function CreateGroupPage() {
+  return (
+    <main className="mx-auto max-w-md px-4 py-8">
+      <h1 className="text-2xl font-bold mb-6">Create a Group</h1>
+      <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
+        Create a group and share the invite code with friends to start building your movie list
+        together.
+      </p>
+      <CreateGroupForm />
+    </main>
+  );
+}

+ 41 - 0
src/app/(app)/home/page.tsx

@@ -0,0 +1,41 @@
+"use client";
+
+import Link from "next/link";
+import { ListGrid } from "@/components/home/list-grid";
+
+export default function HomePage() {
+  return (
+    <div className="mx-auto max-w-3xl px-4 py-8">
+      <section className="mb-8">
+        <h1 className="text-2xl font-bold text-foreground">Your Lists</h1>
+        <p className="mt-1 text-sm text-foreground/60">Pick a list or roll across all of them.</p>
+        <div className="mt-4 flex flex-wrap gap-3">
+          <button
+            type="button"
+            disabled
+            className="rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground/40 cursor-not-allowed"
+            title="Coming soon"
+          >
+            🎲 Roll the Dice
+          </button>
+          <button
+            type="button"
+            disabled
+            className="rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground/40 cursor-not-allowed"
+            title="Coming soon"
+          >
+            🎭 Genre Roll
+          </button>
+          <Link
+            href="/create"
+            className="rounded-lg bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
+          >
+            + Create List
+          </Link>
+        </div>
+      </section>
+
+      <ListGrid />
+    </div>
+  );
+}

+ 17 - 0
src/app/(app)/join/page.tsx

@@ -0,0 +1,17 @@
+import { JoinForm } from "@/components/groups/join-form";
+
+export const metadata = {
+  title: "Join Group - MovieDice",
+};
+
+export default function JoinPage() {
+  return (
+    <main className="mx-auto max-w-md px-4 py-8">
+      <h1 className="text-2xl font-bold mb-6">Join a Group</h1>
+      <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
+        Enter the invite code shared with you to join an existing group.
+      </p>
+      <JoinForm />
+    </main>
+  );
+}

+ 16 - 0
src/app/(app)/layout.tsx

@@ -0,0 +1,16 @@
+import Link from "next/link";
+
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <div className="flex min-h-screen flex-col">
+      <header className="border-b border-foreground/10">
+        <nav className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
+          <Link href="/" className="text-lg font-bold text-foreground">
+            MovieDice
+          </Link>
+        </nav>
+      </header>
+      <main className="flex-1">{children}</main>
+    </div>
+  );
+}

+ 3 - 0
src/app/(public)/layout.tsx

@@ -0,0 +1,3 @@
+export default function PublicLayout({ children }: { children: React.ReactNode }) {
+  return <>{children}</>;
+}

+ 34 - 0
src/app/(public)/page.tsx

@@ -0,0 +1,34 @@
+import Link from "next/link";
+import { Hero } from "@/components/landing/hero";
+import { ReelAnimation } from "@/components/landing/reel-animation";
+import { GenreRollLanding } from "@/components/landing/genre-roll-landing";
+import { AboutSection } from "@/components/landing/about-section";
+import { HowItWorks } from "@/components/landing/how-it-works";
+
+export default function LandingPage() {
+  return (
+    <main className="flex min-h-screen flex-col items-center">
+      <Hero />
+
+      <section className="w-full py-8">
+        <ReelAnimation />
+      </section>
+
+      <section className="flex w-full flex-col items-center gap-4 py-8">
+        <GenreRollLanding />
+      </section>
+
+      <div className="py-6">
+        <Link
+          href="/login"
+          className="rounded-xl border border-foreground/20 px-8 py-3 text-lg font-semibold transition-colors hover:bg-foreground/5"
+        >
+          Login / Get Started
+        </Link>
+      </div>
+
+      <AboutSection />
+      <HowItWorks />
+    </main>
+  );
+}

+ 226 - 0
src/app/(public)/privacy/page.tsx

@@ -0,0 +1,226 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+  title: "Privacy Policy - MovieDice",
+  description: "MovieDice privacy policy and data handling practices.",
+};
+
+export default function PrivacyPage() {
+  return (
+    <main className="mx-auto max-w-3xl px-4 py-12">
+      <h1 className="text-3xl font-bold">Privacy Policy</h1>
+      <p className="mt-2 text-sm text-foreground/50">Last updated: April 6, 2026</p>
+
+      <div className="mt-8 space-y-8 text-foreground/80 leading-relaxed [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-base [&_h3]:font-medium [&_h3]:text-foreground [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1">
+        <section>
+          <h2>1. Controller Identity</h2>
+          <p className="mt-2">
+            MovieDice is operated as a self-hosted application by its administrator (the
+            &quot;Controller&quot;). The Controller is responsible for determining the purposes and
+            means of processing personal data collected through this application. For data-related
+            inquiries, contact the site administrator through the channels provided on this site.
+          </p>
+        </section>
+
+        <section>
+          <h2>2. Lawful Basis for Processing</h2>
+          <p className="mt-2">We process personal data under the following lawful bases:</p>
+          <ul className="mt-2">
+            <li>
+              <strong>Legitimate interest:</strong> Processing anonymous identifiers and group
+              membership data to provide the core movie list and randomizer functionality.
+            </li>
+            <li>
+              <strong>Consent:</strong> Where required by applicable law, your continued use of the
+              service constitutes consent to the processing described in this policy.
+            </li>
+            <li>
+              <strong>Legal obligation:</strong> We may process data to comply with applicable legal
+              requirements.
+            </li>
+          </ul>
+        </section>
+
+        <section>
+          <h2>3. Data Inventory and Retention</h2>
+          <p className="mt-2">We collect and store the following data:</p>
+
+          <h3 className="mt-4">Anonymous User Identifier (UUID)</h3>
+          <p className="mt-1">
+            A randomly generated unique identifier created via Supabase Anonymous Sign-In. This is
+            not linked to any email, phone number, or real-world identity. Retained until 12 months
+            of inactivity, after which the account and all associated data are automatically
+            deleted.
+          </p>
+
+          <h3 className="mt-4">Display Name</h3>
+          <p className="mt-1">
+            A user-chosen name (up to 30 characters) used to identify contributions within a group.
+            This is not verified and does not need to be a real name. Retained for the lifetime of
+            the account.
+          </p>
+
+          <h3 className="mt-4">Group Membership</h3>
+          <p className="mt-1">
+            Records of which groups a user belongs to and their role (admin or member). Deleted when
+            a user leaves a group or when the account is deleted.
+          </p>
+
+          <h3 className="mt-4">Movie Preferences</h3>
+          <p className="mt-1">
+            Movies added to group lists, including which user added them and watched status. The
+            association with a specific user is set to null if the user&apos;s account is deleted
+            (the movie remains on the list). Retained for the lifetime of the group.
+          </p>
+
+          <h3 className="mt-4">Recovery Code</h3>
+          <p className="mt-1">
+            A one-time-use 24-character code hashed with Argon2id before storage. The plaintext is
+            shown once and never stored. The hash is deleted after successful use or account
+            deletion.
+          </p>
+
+          <h3 className="mt-4">Server Logs</h3>
+          <p className="mt-1">
+            Standard HTTP server logs including IP addresses, user agent strings, request paths, and
+            timestamps. These are used for security monitoring and debugging. Log rotation is
+            configured with a maximum size of 10MB per file and a maximum of 5 files, resulting in
+            automatic deletion as logs rotate.
+          </p>
+        </section>
+
+        <section>
+          <h2>4. Third-Party Recipients</h2>
+          <ul className="mt-2">
+            <li>
+              <strong>TMDB (The Movie Database):</strong> We send API requests to TMDB to search for
+              movies and retrieve movie metadata (posters, titles, genres). These requests are made
+              server-side and do not include your user identifier. TMDB&apos;s privacy policy is
+              available at{" "}
+              <a
+                href="https://www.themoviedb.org/privacy-policy"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="underline hover:text-foreground"
+              >
+                themoviedb.org/privacy-policy
+              </a>
+              .
+            </li>
+            <li>
+              <strong>Sentry:</strong> We use Sentry for error monitoring. Error reports may include
+              request metadata but never include user identifiers. UUID path segments are stripped
+              before transmission. Sentry&apos;s privacy policy is available at{" "}
+              <a
+                href="https://sentry.io/privacy/"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="underline hover:text-foreground"
+              >
+                sentry.io/privacy
+              </a>
+              .
+            </li>
+          </ul>
+        </section>
+
+        <section>
+          <h2>5. International Transfers</h2>
+          <p className="mt-2">
+            Error monitoring data sent to Sentry may be processed on servers located in the United
+            States. Sentry participates in the EU-US Data Privacy Framework. All other data (user
+            accounts, group data, movie lists) is stored on the self-hosted server and does not
+            leave the hosting jurisdiction unless explicitly configured otherwise by the
+            administrator.
+          </p>
+        </section>
+
+        <section>
+          <h2>6. Your Rights</h2>
+          <p className="mt-2">
+            Depending on your jurisdiction, you may have the following rights regarding your
+            personal data:
+          </p>
+          <ul className="mt-2">
+            <li>
+              <strong>Right of access:</strong> Request a copy of the data we hold about you.
+            </li>
+            <li>
+              <strong>Right to rectification:</strong> Update your display name at any time within
+              the app.
+            </li>
+            <li>
+              <strong>Right to erasure:</strong> Delete your account, which removes your user
+              record, group memberships, and nullifies movie attribution.
+            </li>
+            <li>
+              <strong>Right to restrict processing:</strong> Contact the administrator to request
+              processing restrictions.
+            </li>
+            <li>
+              <strong>Right to data portability:</strong> Contact the administrator to request your
+              data in a machine-readable format.
+            </li>
+            <li>
+              <strong>Right to object:</strong> Contact the administrator to object to processing
+              based on legitimate interest.
+            </li>
+          </ul>
+          <p className="mt-2">
+            To exercise these rights, contact the site administrator. You may also have the right to
+            lodge a complaint with your local data protection authority.
+          </p>
+        </section>
+
+        <section>
+          <h2>7. Children&apos;s Privacy</h2>
+          <p className="mt-2">
+            MovieDice is not intended for use by children under the age of 13. We do not knowingly
+            collect personal data from children under 13. In the European Economic Area, the service
+            is not intended for users under the age of 16 without parental consent, in accordance
+            with the GDPR. If you believe a child has provided data through this service, please
+            contact the administrator to request its deletion.
+          </p>
+        </section>
+
+        <section>
+          <h2>8. Cookies and Local Storage</h2>
+          <p className="mt-2">MovieDice uses the following browser storage mechanisms:</p>
+          <ul className="mt-2">
+            <li>
+              <strong>Authentication cookies:</strong> HttpOnly, Secure, SameSite=Strict cookies
+              managed by Supabase for session authentication. These are essential for the service to
+              function and cannot be disabled.
+            </li>
+            <li>
+              <strong>Admin session cookies:</strong> Encrypted iron-session cookies for admin panel
+              authentication (8-hour expiry).
+            </li>
+            <li>
+              <strong>IndexedDB:</strong> Used for offline caching of movie list data via TanStack
+              Query persistence. This data stays on your device and is not transmitted to any
+              server.
+            </li>
+            <li>
+              <strong>localStorage:</strong> May be used by Supabase client libraries for token
+              management.
+            </li>
+          </ul>
+          <p className="mt-2">
+            We do not use any third-party tracking cookies or analytics cookies.
+          </p>
+        </section>
+
+        <section>
+          <h2>9. Changes to This Policy</h2>
+          <p className="mt-2">
+            We may update this privacy policy from time to time. Changes will be indicated by
+            updating the &quot;Last updated&quot; date at the top of this page. For significant
+            changes, we will display a notice within the application. Continued use of the service
+            after changes constitutes acceptance of the updated policy.
+          </p>
+        </section>
+      </div>
+    </main>
+  );
+}

+ 3 - 0
src/app/admin/layout.tsx

@@ -0,0 +1,3 @@
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+  return <>{children}</>;
+}

+ 90 - 0
src/app/admin/login/page.tsx

@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export default function AdminLoginPage() {
+  const router = useRouter();
+  const [username, setUsername] = useState("");
+  const [totpCode, setTotpCode] = useState("");
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    setLoading(true);
+    setError(null);
+
+    try {
+      const res = await fetch("/api/admin/login", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ username, totpCode }),
+      });
+
+      if (!res.ok) {
+        const body = await res.json();
+        setError(body.error ?? "Login failed");
+        setLoading(false);
+        return;
+      }
+
+      router.push("/admin");
+    } catch {
+      setError("Network error");
+      setLoading(false);
+    }
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
+      <div className="w-full max-w-sm">
+        <h1 className="mb-8 text-center text-2xl font-bold text-white">Admin Login</h1>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label htmlFor="username" className="mb-1 block text-sm text-neutral-300">
+              Username
+            </label>
+            <input
+              id="username"
+              type="text"
+              value={username}
+              onChange={(e) => setUsername(e.target.value)}
+              required
+              autoComplete="username"
+              className="w-full rounded border border-neutral-700 bg-neutral-900 px-3 py-2 text-white placeholder-neutral-500 focus:border-neutral-500 focus:outline-none"
+            />
+          </div>
+          <div>
+            <label htmlFor="totp" className="mb-1 block text-sm text-neutral-300">
+              TOTP Code
+            </label>
+            <input
+              id="totp"
+              type="text"
+              inputMode="numeric"
+              pattern="[0-9]{6}"
+              maxLength={6}
+              value={totpCode}
+              onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
+              required
+              autoComplete="one-time-code"
+              className="w-full rounded border border-neutral-700 bg-neutral-900 px-3 py-2 text-white placeholder-neutral-500 focus:border-neutral-500 focus:outline-none"
+              placeholder="000000"
+            />
+          </div>
+
+          {error && <p className="text-sm text-red-400">{error}</p>}
+
+          <button
+            type="submit"
+            disabled={loading}
+            className="w-full rounded bg-white py-2 text-sm font-medium text-black hover:bg-neutral-200 disabled:opacity-50"
+          >
+            {loading ? "Signing in..." : "Sign in"}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 21 - 0
src/app/admin/logout-button.tsx

@@ -0,0 +1,21 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+export function AdminLogoutButton() {
+  const router = useRouter();
+
+  async function handleLogout() {
+    await fetch("/api/admin/logout", { method: "POST" });
+    router.push("/admin/login");
+  }
+
+  return (
+    <button
+      onClick={handleLogout}
+      className="rounded bg-neutral-800 px-3 py-1.5 text-sm text-neutral-300 hover:bg-neutral-700"
+    >
+      Logout
+    </button>
+  );
+}

+ 23 - 0
src/app/admin/page.tsx

@@ -0,0 +1,23 @@
+import { redirect } from "next/navigation";
+import { getAdminSession } from "@/lib/admin/session";
+import { SearchPanel } from "@/components/admin/search-panel";
+import { AdminLogoutButton } from "./logout-button";
+
+export default async function AdminDashboardPage() {
+  const session = await getAdminSession();
+  if (!session.isAdmin) {
+    redirect("/admin/login");
+  }
+
+  return (
+    <div className="min-h-screen bg-neutral-950 px-4 py-8">
+      <div className="mx-auto max-w-2xl">
+        <div className="mb-8 flex items-center justify-between">
+          <h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
+          <AdminLogoutButton />
+        </div>
+        <SearchPanel />
+      </div>
+    </div>
+  );
+}

+ 29 - 0
src/app/api/admin/groups/[id]/route.ts

@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAdminSession } from "@/lib/admin/session";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { UUID_REGEX } from "@/lib/constants";
+
+export async function DELETE(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const session = await getAdminSession();
+  if (!session.isAdmin) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const { id } = await params;
+
+  if (!UUID_REGEX.test(id)) {
+    return NextResponse.json({ error: "Invalid group ID" }, { status: 400 });
+  }
+
+  const supabase = getSupabaseAdminClient();
+
+  const { error } = await supabase.from("groups").delete().eq("id", id);
+  if (error) {
+    return NextResponse.json({ error: error.message }, { status: 500 });
+  }
+
+  return NextResponse.json({ ok: true });
+}

+ 38 - 0
src/app/api/admin/groups/route.ts

@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAdminSession } from "@/lib/admin/session";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { UUID_REGEX } from "@/lib/constants";
+
+export async function GET(request: NextRequest) {
+  const session = await getAdminSession();
+  if (!session.isAdmin) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const q = request.nextUrl.searchParams.get("q")?.trim();
+  if (!q) {
+    return NextResponse.json({ error: "Query parameter 'q' is required" }, { status: 400 });
+  }
+
+  const supabase = getSupabaseAdminClient();
+
+  if (UUID_REGEX.test(q)) {
+    const { data, error } = await supabase.from("groups").select("*").eq("id", q).limit(1);
+    if (error) {
+      return NextResponse.json({ error: error.message }, { status: 500 });
+    }
+    return NextResponse.json({ groups: data });
+  }
+
+  const { data, error } = await supabase
+    .from("groups")
+    .select("*")
+    .ilike("name", `%${q}%`)
+    .limit(20);
+
+  if (error) {
+    return NextResponse.json({ error: error.message }, { status: 500 });
+  }
+
+  return NextResponse.json({ groups: data });
+}

+ 38 - 0
src/app/api/admin/login/route.ts

@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getAdminSession } from "@/lib/admin/session";
+import { verifyTotp } from "@/lib/admin/totp";
+
+const loginSchema = z.object({
+  username: z.string().min(1),
+  totpCode: z.string().min(6).max(6),
+});
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json();
+    const parsed = loginSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: "Invalid input" }, { status: 400 });
+    }
+
+    const { username, totpCode } = parsed.data;
+
+    if (username !== process.env.MASTER_ADMIN_USERNAME) {
+      return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+    }
+
+    if (!verifyTotp(totpCode)) {
+      return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+    }
+
+    const session = await getAdminSession();
+    session.isAdmin = true;
+    await session.save();
+
+    return NextResponse.json({ ok: true });
+  } catch {
+    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+  }
+}

+ 8 - 0
src/app/api/admin/logout/route.ts

@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { getAdminSession } from "@/lib/admin/session";
+
+export async function POST() {
+  const session = await getAdminSession();
+  session.destroy();
+  return NextResponse.json({ ok: true });
+}

+ 35 - 0
src/app/api/admin/users/[id]/route.ts

@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAdminSession } from "@/lib/admin/session";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { UUID_REGEX } from "@/lib/constants";
+
+export async function DELETE(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const session = await getAdminSession();
+  if (!session.isAdmin) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const { id } = await params;
+
+  if (!UUID_REGEX.test(id)) {
+    return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
+  }
+
+  const supabase = getSupabaseAdminClient();
+
+  // public.users first so FK cascades run before auth record removal
+  const { error: dbError } = await supabase.from("users").delete().eq("id", id);
+  if (dbError) {
+    return NextResponse.json({ error: dbError.message }, { status: 500 });
+  }
+
+  const { error: authError } = await supabase.auth.admin.deleteUser(id);
+  if (authError) {
+    return NextResponse.json({ error: authError.message }, { status: 500 });
+  }
+
+  return NextResponse.json({ ok: true });
+}

+ 38 - 0
src/app/api/admin/users/route.ts

@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAdminSession } from "@/lib/admin/session";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { UUID_REGEX } from "@/lib/constants";
+
+export async function GET(request: NextRequest) {
+  const session = await getAdminSession();
+  if (!session.isAdmin) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const q = request.nextUrl.searchParams.get("q")?.trim();
+  if (!q) {
+    return NextResponse.json({ error: "Query parameter 'q' is required" }, { status: 400 });
+  }
+
+  const supabase = getSupabaseAdminClient();
+
+  if (UUID_REGEX.test(q)) {
+    const { data, error } = await supabase.from("users").select("*").eq("id", q).limit(1);
+    if (error) {
+      return NextResponse.json({ error: error.message }, { status: 500 });
+    }
+    return NextResponse.json({ users: data });
+  }
+
+  const { data, error } = await supabase
+    .from("users")
+    .select("*")
+    .ilike("display_name", `%${q}%`)
+    .limit(20);
+
+  if (error) {
+    return NextResponse.json({ error: error.message }, { status: 500 });
+  }
+
+  return NextResponse.json({ users: data });
+}

+ 49 - 0
src/app/api/groups/[id]/invite/route.ts

@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { generateInviteCode } from "@/lib/groups/invite-code";
+
+/** POST /api/groups/[id]/invite — Regenerate invite code (admin only) */
+export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership || membership.role !== "admin") {
+      return NextResponse.json({ error: "Admin access required" }, { status: 403 });
+    }
+
+    const newCode = await generateInviteCode();
+
+    const { data: group, error } = await admin
+      .from("groups")
+      .update({ invite_code: newCode })
+      .eq("id", id)
+      .select()
+      .single();
+
+    if (error || !group) {
+      return NextResponse.json({ error: "Failed to regenerate invite code" }, { status: 500 });
+    }
+
+    return NextResponse.json({ invite_code: group.invite_code });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 66 - 0
src/app/api/groups/[id]/leave/route.ts

@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { deleteGroupAndData } from "@/lib/groups/delete-group";
+
+/** POST /api/groups/[id]/leave — Leave a group */
+export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership) {
+      return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
+    }
+
+    const { count } = await admin
+      .from("group_members")
+      .select("*", { count: "exact", head: true })
+      .eq("group_id", id);
+
+    const isLastMember = count === 1;
+
+    // If admin with other members, must transfer first
+    if (membership.role === "admin" && !isLastMember) {
+      return NextResponse.json(
+        { error: "Transfer admin role before leaving. Use /api/groups/[id]/transfer first." },
+        { status: 400 },
+      );
+    }
+
+    if (isLastMember) {
+      await deleteGroupAndData(id);
+      return NextResponse.json({ success: true, groupDeleted: true });
+    }
+
+    const { error } = await admin
+      .from("group_members")
+      .delete()
+      .eq("group_id", id)
+      .eq("user_id", user.id);
+
+    if (error) {
+      return NextResponse.json({ error: "Failed to leave group" }, { status: 500 });
+    }
+
+    return NextResponse.json({ success: true, groupDeleted: false });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 132 - 0
src/app/api/groups/[id]/members/route.ts

@@ -0,0 +1,132 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+
+const removeMemberSchema = z.object({
+  user_id: z.string().uuid("Invalid user ID"),
+});
+
+/** GET /api/groups/[id]/members — List group members */
+export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership) {
+      return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
+    }
+
+    const { data: members, error } = await admin
+      .from("group_members")
+      .select("user_id, role, joined_at, users(display_name, avatar_color)")
+      .eq("group_id", id)
+      .order("joined_at", { ascending: true });
+
+    if (error) {
+      return NextResponse.json({ error: "Failed to fetch members" }, { status: 500 });
+    }
+
+    return NextResponse.json({ members, currentUserRole: membership.role });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}
+
+/** DELETE /api/groups/[id]/members — Remove a member (admin or self-leave) */
+export async function DELETE(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = await request.json();
+    const parsed = removeMemberSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership) {
+      return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
+    }
+
+    const isSelfRemoval = parsed.data.user_id === user.id;
+
+    if (!isSelfRemoval && membership.role !== "admin") {
+      return NextResponse.json({ error: "Admin access required" }, { status: 403 });
+    }
+
+    if (isSelfRemoval && membership.role === "admin") {
+      return NextResponse.json(
+        { error: "Admins must use the leave endpoint to leave" },
+        { status: 400 },
+      );
+    }
+
+    if (!isSelfRemoval) {
+      const { data: target } = await admin
+        .from("group_members")
+        .select("role")
+        .eq("group_id", id)
+        .eq("user_id", parsed.data.user_id)
+        .maybeSingle();
+
+      if (!target) {
+        return NextResponse.json({ error: "User is not a member" }, { status: 404 });
+      }
+
+      if (target.role === "admin") {
+        return NextResponse.json({ error: "Cannot remove an admin" }, { status: 403 });
+      }
+    }
+
+    const { error } = await admin
+      .from("group_members")
+      .delete()
+      .eq("group_id", id)
+      .eq("user_id", parsed.data.user_id);
+
+    if (error) {
+      return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
+    }
+
+    return NextResponse.json({ success: true });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 134 - 0
src/app/api/groups/[id]/route.ts

@@ -0,0 +1,134 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { groupNameSchema } from "@/lib/groups/validation";
+import { deleteGroupAndData } from "@/lib/groups/delete-group";
+
+const renameSchema = z.object({ name: groupNameSchema });
+
+/** GET /api/groups/[id] — Get group details */
+export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const [{ data: membership }, { data: group, error }] = await Promise.all([
+      admin
+        .from("group_members")
+        .select("role")
+        .eq("group_id", id)
+        .eq("user_id", user.id)
+        .maybeSingle(),
+      admin.from("groups").select("*").eq("id", id).single(),
+    ]);
+
+    if (!membership) {
+      return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
+    }
+
+    if (error || !group) {
+      return NextResponse.json({ error: "Group not found" }, { status: 404 });
+    }
+
+    return NextResponse.json({ group, role: membership.role });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}
+
+/** PATCH /api/groups/[id] — Rename group (admin only) */
+export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership || membership.role !== "admin") {
+      return NextResponse.json({ error: "Admin access required" }, { status: 403 });
+    }
+
+    const body = await request.json();
+    const parsed = renameSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    const { data: group, error } = await admin
+      .from("groups")
+      .update({ name: parsed.data.name })
+      .eq("id", id)
+      .select()
+      .single();
+
+    if (error || !group) {
+      return NextResponse.json({ error: "Failed to rename group" }, { status: 500 });
+    }
+
+    return NextResponse.json({ group });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}
+
+/** DELETE /api/groups/[id] — Delete group (admin only) */
+export async function DELETE(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: membership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!membership || membership.role !== "admin") {
+      return NextResponse.json({ error: "Admin access required" }, { status: 403 });
+    }
+
+    await deleteGroupAndData(id);
+
+    return NextResponse.json({ success: true });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 88 - 0
src/app/api/groups/[id]/transfer/route.ts

@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+
+const transferSchema = z.object({
+  new_admin_id: z.string().uuid("Invalid user ID"),
+});
+
+/** POST /api/groups/[id]/transfer — Transfer admin role to another member */
+export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  try {
+    const { id } = await params;
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = await request.json();
+    const parsed = transferSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    if (parsed.data.new_admin_id === user.id) {
+      return NextResponse.json({ error: "Cannot transfer to yourself" }, { status: 400 });
+    }
+
+    const admin = getSupabaseAdminClient();
+
+    const { data: currentMembership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (!currentMembership || currentMembership.role !== "admin") {
+      return NextResponse.json({ error: "Admin access required" }, { status: 403 });
+    }
+
+    const { data: targetMembership } = await admin
+      .from("group_members")
+      .select("role")
+      .eq("group_id", id)
+      .eq("user_id", parsed.data.new_admin_id)
+      .maybeSingle();
+
+    if (!targetMembership) {
+      return NextResponse.json({ error: "Target user is not a member" }, { status: 404 });
+    }
+
+    const { error: promoteError } = await admin
+      .from("group_members")
+      .update({ role: "admin" })
+      .eq("group_id", id)
+      .eq("user_id", parsed.data.new_admin_id);
+
+    if (promoteError) {
+      return NextResponse.json({ error: "Failed to transfer admin role" }, { status: 500 });
+    }
+
+    const { error: demoteError } = await admin
+      .from("group_members")
+      .update({ role: "member" })
+      .eq("group_id", id)
+      .eq("user_id", user.id);
+
+    if (demoteError) {
+      // Rollback: restore target to member
+      await admin
+        .from("group_members")
+        .update({ role: "member" })
+        .eq("group_id", id)
+        .eq("user_id", parsed.data.new_admin_id);
+      return NextResponse.json({ error: "Failed to transfer admin role" }, { status: 500 });
+    }
+
+    return NextResponse.json({ success: true });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 91 - 0
src/app/api/groups/join/route.ts

@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { normalizeInviteCode } from "@/lib/groups/invite-code";
+import { checkRateLimit } from "@/lib/groups/rate-limit";
+
+const joinGroupSchema = z.object({
+  invite_code: z.string().min(1, "Invite code is required"),
+});
+
+/** POST /api/groups/join — Join a group via invite code */
+export async function POST(request: NextRequest) {
+  try {
+    const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
+    const { allowed, remaining, resetAt } = checkRateLimit(ip);
+
+    if (!allowed) {
+      return NextResponse.json(
+        { error: "Too many join attempts. Please try again later." },
+        {
+          status: 429,
+          headers: {
+            "Retry-After": String(Math.ceil((resetAt - Date.now()) / 1000)),
+            "X-RateLimit-Remaining": String(remaining),
+          },
+        },
+      );
+    }
+
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = await request.json();
+    const parsed = joinGroupSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    const code = normalizeInviteCode(parsed.data.invite_code);
+    const admin = getSupabaseAdminClient();
+
+    const { data: group, error: groupError } = await admin
+      .from("groups")
+      .select("id, name")
+      .eq("invite_code", code)
+      .maybeSingle();
+
+    if (groupError || !group) {
+      return NextResponse.json({ error: "Invalid invite code" }, { status: 404 });
+    }
+
+    const { data: existing } = await admin
+      .from("group_members")
+      .select("group_id")
+      .eq("group_id", group.id)
+      .eq("user_id", user.id)
+      .maybeSingle();
+
+    if (existing) {
+      return NextResponse.json({ error: "Already a member of this group" }, { status: 409 });
+    }
+
+    const { error: joinError } = await admin.from("group_members").insert({
+      group_id: group.id,
+      user_id: user.id,
+      role: "member",
+    });
+
+    if (joinError) {
+      return NextResponse.json({ error: "Failed to join group" }, { status: 500 });
+    }
+
+    return NextResponse.json(
+      { group: { id: group.id, name: group.name } },
+      {
+        status: 200,
+        headers: { "X-RateLimit-Remaining": String(remaining - 1) },
+      },
+    );
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 61 - 0
src/app/api/groups/route.ts

@@ -0,0 +1,61 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { generateInviteCode } from "@/lib/groups/invite-code";
+import { groupNameSchema } from "@/lib/groups/validation";
+
+const createGroupSchema = z.object({ name: groupNameSchema });
+
+/** POST /api/groups — Create a new group */
+export async function POST(request: NextRequest) {
+  try {
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const body = await request.json();
+    const parsed = createGroupSchema.safeParse(body);
+
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    const inviteCode = await generateInviteCode();
+    const admin = getSupabaseAdminClient();
+
+    const { data: group, error: groupError } = await admin
+      .from("groups")
+      .insert({
+        name: parsed.data.name,
+        invite_code: inviteCode,
+        created_by: user.id,
+      })
+      .select()
+      .single();
+
+    if (groupError) {
+      return NextResponse.json({ error: "Failed to create group" }, { status: 500 });
+    }
+
+    const { error: memberError } = await admin.from("group_members").insert({
+      group_id: group.id,
+      user_id: user.id,
+      role: "admin",
+    });
+
+    if (memberError) {
+      await admin.from("groups").delete().eq("id", group.id);
+      return NextResponse.json({ error: "Failed to add member" }, { status: 500 });
+    }
+
+    return NextResponse.json({ group }, { status: 201 });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 51 - 0
src/app/api/movies/[id]/_helpers.ts

@@ -0,0 +1,51 @@
+import { NextResponse } from "next/server";
+import type { Database } from "@/types/database";
+
+type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
+
+type MovieAccessResult =
+  | { ok: true; userId: string; movie: Pick<MovieRow, "id" | "group_id"> }
+  | { ok: false; response: NextResponse };
+
+export async function verifyMovieAccess(
+  // Supabase v2 generics resolve movies table to `never`; typed client param breaks downstream queries
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  supabase: any,
+  movieId: string,
+): Promise<MovieAccessResult> {
+  const {
+    data: { user },
+  } = await supabase.auth.getUser();
+  if (!user) {
+    return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
+  }
+
+  const { data: movie, error: fetchError } = await supabase
+    .from("movies")
+    .select("id, group_id")
+    .eq("id", movieId)
+    .single();
+
+  if (fetchError || !movie) {
+    return {
+      ok: false,
+      response: NextResponse.json({ error: "Movie not found" }, { status: 404 }),
+    };
+  }
+
+  const { data: membership } = await supabase
+    .from("group_members")
+    .select("user_id")
+    .eq("group_id", movie.group_id)
+    .eq("user_id", user.id)
+    .single();
+
+  if (!membership) {
+    return {
+      ok: false,
+      response: NextResponse.json({ error: "Not a group member" }, { status: 403 }),
+    };
+  }
+
+  return { ok: true, userId: user.id, movie };
+}

+ 55 - 0
src/app/api/movies/[id]/route.ts

@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { verifyMovieAccess } from "./_helpers";
+
+const patchSchema = z.object({
+  title: z.string().min(1).optional(),
+  trailer_url: z.string().url().nullable().optional(),
+  genres: z.array(z.string()).optional(),
+});
+
+export async function DELETE(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const supabase = await getSupabaseServerClient();
+
+  const access = await verifyMovieAccess(supabase, id);
+  if (!access.ok) return access.response;
+
+  const { error } = await supabase.from("movies").delete().eq("id", id);
+  if (error) {
+    return NextResponse.json({ error: "Failed to delete movie" }, { status: 500 });
+  }
+
+  return NextResponse.json({ success: true });
+}
+
+export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params;
+  const supabase = await getSupabaseServerClient();
+
+  const access = await verifyMovieAccess(supabase, id);
+  if (!access.ok) return access.response;
+
+  const body = await request.json().catch(() => null);
+  const parsed = patchSchema.safeParse(body);
+  if (!parsed.success) {
+    return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const { data: updated, error } = await (supabase.from("movies") as any)
+    .update(parsed.data)
+    .eq("id", id)
+    .select()
+    .single();
+
+  if (error) {
+    return NextResponse.json({ error: "Failed to update movie" }, { status: 500 });
+  }
+
+  return NextResponse.json(updated);
+}

+ 39 - 0
src/app/api/movies/[id]/watched/route.ts

@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { verifyMovieAccess } from "../_helpers";
+
+const watchedSchema = z.object({
+  watched: z.boolean(),
+});
+
+export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params;
+  const supabase = await getSupabaseServerClient();
+
+  const access = await verifyMovieAccess(supabase, id);
+  if (!access.ok) return access.response;
+
+  const body = await request.json().catch(() => null);
+  const parsed = watchedSchema.safeParse(body);
+  if (!parsed.success) {
+    return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+  }
+
+  const { watched } = parsed.data;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const { data: updated, error } = await (supabase.from("movies") as any)
+    .update({
+      watched,
+      watched_at: watched ? new Date().toISOString() : null,
+    })
+    .eq("id", id)
+    .select()
+    .single();
+
+  if (error) {
+    return NextResponse.json({ error: "Failed to update watched status" }, { status: 500 });
+  }
+
+  return NextResponse.json(updated);
+}

+ 31 - 0
src/app/api/tmdb/reel-posters/route.ts

@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { REEL_POSTER_COUNT } from "@/lib/constants";
+import { fetchTMDBMovies } from "@/lib/tmdb-fetch";
+
+// Fallback: fetch popular movies for reel posters.
+// In production, the cron job populates the landing_reel_posters table.
+export async function GET() {
+  try {
+    const params = new URLSearchParams({
+      language: "en-US",
+      page: "1",
+      include_adult: "false",
+    });
+
+    const { movies, error, status } = await fetchTMDBMovies("/movie/popular", params);
+
+    if (error) {
+      return NextResponse.json({ error }, { status: status ?? 502 });
+    }
+
+    const posters = movies.slice(0, REEL_POSTER_COUNT).map((m) => ({
+      tmdb_id: m.id,
+      poster_path: m.poster_path!,
+      title: m.title,
+    }));
+
+    return NextResponse.json({ posters });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 79 - 0
src/components/admin/group-card.tsx

@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import type { Database } from "@/types/database";
+
+type Group = Database["public"]["Tables"]["groups"]["Row"];
+
+interface GroupCardProps {
+  group: Group;
+  onDeleted: () => void;
+}
+
+export function GroupCard({ group, onDeleted }: GroupCardProps) {
+  const [confirming, setConfirming] = useState(false);
+  const [deleting, setDeleting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  async function handleDelete() {
+    setDeleting(true);
+    setError(null);
+    try {
+      const res = await fetch(`/api/admin/groups/${group.id}`, { method: "DELETE" });
+      if (!res.ok) {
+        const body = await res.json();
+        setError(body.error ?? "Delete failed");
+        setDeleting(false);
+        return;
+      }
+      onDeleted();
+    } catch {
+      setError("Network error");
+      setDeleting(false);
+    }
+  }
+
+  return (
+    <div className="rounded-lg border border-neutral-700 bg-neutral-900 p-4">
+      <div className="mb-2">
+        <p className="font-semibold text-white">{group.name}</p>
+        <p className="font-mono text-xs text-neutral-400">{group.id}</p>
+      </div>
+      <p className="text-sm text-neutral-400">Invite code: {group.invite_code}</p>
+      <p className="text-sm text-neutral-400">
+        Created: {new Date(group.created_at).toLocaleDateString()}
+      </p>
+
+      {error && <p className="mt-2 text-sm text-red-400">{error}</p>}
+
+      <div className="mt-3">
+        {!confirming ? (
+          <button
+            onClick={() => setConfirming(true)}
+            className="rounded bg-red-800 px-3 py-1.5 text-sm text-white hover:bg-red-700"
+          >
+            Delete group
+          </button>
+        ) : (
+          <div className="flex items-center gap-2">
+            <span className="text-sm text-red-400">Are you sure? This deletes all group data.</span>
+            <button
+              onClick={handleDelete}
+              disabled={deleting}
+              className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-500 disabled:opacity-50"
+            >
+              {deleting ? "Deleting..." : "Confirm"}
+            </button>
+            <button
+              onClick={() => setConfirming(false)}
+              disabled={deleting}
+              className="rounded bg-neutral-700 px-3 py-1.5 text-sm text-white hover:bg-neutral-600"
+            >
+              Cancel
+            </button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 124 - 0
src/components/admin/search-panel.tsx

@@ -0,0 +1,124 @@
+"use client";
+
+import { useState } from "react";
+import type { Database } from "@/types/database";
+import { UserCard } from "./user-card";
+import { GroupCard } from "./group-card";
+
+type User = Database["public"]["Tables"]["users"]["Row"];
+type Group = Database["public"]["Tables"]["groups"]["Row"];
+type SearchType = "users" | "groups";
+
+export function SearchPanel() {
+  const [searchType, setSearchType] = useState<SearchType>("users");
+  const [query, setQuery] = useState("");
+  const [users, setUsers] = useState<User[]>([]);
+  const [groups, setGroups] = useState<Group[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [searched, setSearched] = useState(false);
+
+  async function runSearch() {
+    const trimmed = query.trim();
+    if (!trimmed) return;
+
+    setLoading(true);
+    setError(null);
+    setSearched(true);
+
+    try {
+      const res = await fetch(`/api/admin/${searchType}?q=${encodeURIComponent(trimmed)}`);
+      if (!res.ok) {
+        const body = await res.json();
+        setError(body.error ?? "Search failed");
+        setLoading(false);
+        return;
+      }
+      const data = await res.json();
+      if (searchType === "users") {
+        setUsers(data.users);
+        setGroups([]);
+      } else {
+        setGroups(data.groups);
+        setUsers([]);
+      }
+    } catch {
+      setError("Network error");
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    runSearch();
+  }
+
+  function handleTypeChange(type: SearchType) {
+    setSearchType(type);
+    setUsers([]);
+    setGroups([]);
+    setSearched(false);
+    setError(null);
+  }
+
+  return (
+    <div>
+      <div className="mb-4 flex gap-2">
+        <button
+          onClick={() => handleTypeChange("users")}
+          className={`rounded px-4 py-2 text-sm font-medium ${
+            searchType === "users"
+              ? "bg-white text-black"
+              : "bg-neutral-800 text-neutral-300 hover:bg-neutral-700"
+          }`}
+        >
+          Users
+        </button>
+        <button
+          onClick={() => handleTypeChange("groups")}
+          className={`rounded px-4 py-2 text-sm font-medium ${
+            searchType === "groups"
+              ? "bg-white text-black"
+              : "bg-neutral-800 text-neutral-300 hover:bg-neutral-700"
+          }`}
+        >
+          Groups
+        </button>
+      </div>
+
+      <form onSubmit={handleSubmit} className="mb-6 flex gap-2">
+        <input
+          type="text"
+          value={query}
+          onChange={(e) => setQuery(e.target.value)}
+          placeholder={
+            searchType === "users"
+              ? "Search by display name or user ID..."
+              : "Search by group name or group ID..."
+          }
+          className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-3 py-2 text-white placeholder-neutral-500 focus:border-neutral-500 focus:outline-none"
+        />
+        <button
+          type="submit"
+          disabled={loading}
+          className="rounded bg-white px-4 py-2 text-sm font-medium text-black hover:bg-neutral-200 disabled:opacity-50"
+        >
+          {loading ? "Searching..." : "Search"}
+        </button>
+      </form>
+
+      {error && <p className="mb-4 text-sm text-red-400">{error}</p>}
+
+      <div className="space-y-3" aria-live="polite">
+        {searchType === "users" &&
+          users.map((user) => <UserCard key={user.id} user={user} onDeleted={runSearch} />)}
+        {searchType === "groups" &&
+          groups.map((group) => <GroupCard key={group.id} group={group} onDeleted={runSearch} />)}
+        {searched && !loading && users.length === 0 && groups.length === 0 && !error && (
+          <p className="text-neutral-400">No results found.</p>
+        )}
+      </div>
+    </div>
+  );
+}

+ 86 - 0
src/components/admin/user-card.tsx

@@ -0,0 +1,86 @@
+"use client";
+
+import { useState } from "react";
+import type { Database } from "@/types/database";
+
+type User = Database["public"]["Tables"]["users"]["Row"];
+
+interface UserCardProps {
+  user: User;
+  onDeleted: () => void;
+}
+
+export function UserCard({ user, onDeleted }: UserCardProps) {
+  const [confirming, setConfirming] = useState(false);
+  const [deleting, setDeleting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  async function handleDelete() {
+    setDeleting(true);
+    setError(null);
+    try {
+      const res = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
+      if (!res.ok) {
+        const body = await res.json();
+        setError(body.error ?? "Delete failed");
+        setDeleting(false);
+        return;
+      }
+      onDeleted();
+    } catch {
+      setError("Network error");
+      setDeleting(false);
+    }
+  }
+
+  return (
+    <div className="rounded-lg border border-neutral-700 bg-neutral-900 p-4">
+      <div className="mb-2 flex items-start justify-between">
+        <div>
+          <p className="font-semibold text-white">{user.display_name}</p>
+          <p className="font-mono text-xs text-neutral-400">{user.id}</p>
+        </div>
+        {user.avatar_color && (
+          <span className="h-6 w-6 rounded-full" style={{ backgroundColor: user.avatar_color }} />
+        )}
+      </div>
+      <p className="text-sm text-neutral-400">
+        Created: {new Date(user.created_at).toLocaleDateString()}
+      </p>
+      <p className="text-sm text-neutral-400">
+        Last active: {new Date(user.last_active_at).toLocaleDateString()}
+      </p>
+
+      {error && <p className="mt-2 text-sm text-red-400">{error}</p>}
+
+      <div className="mt-3">
+        {!confirming ? (
+          <button
+            onClick={() => setConfirming(true)}
+            className="rounded bg-red-800 px-3 py-1.5 text-sm text-white hover:bg-red-700"
+          >
+            Delete user
+          </button>
+        ) : (
+          <div className="flex items-center gap-2">
+            <span className="text-sm text-red-400">Are you sure?</span>
+            <button
+              onClick={handleDelete}
+              disabled={deleting}
+              className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-500 disabled:opacity-50"
+            >
+              {deleting ? "Deleting..." : "Confirm"}
+            </button>
+            <button
+              onClick={() => setConfirming(false)}
+              disabled={deleting}
+              className="rounded bg-neutral-700 px-3 py-1.5 text-sm text-white hover:bg-neutral-600"
+            >
+              Cancel
+            </button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 44 - 0
src/components/dice/genre-roll-input.tsx

@@ -0,0 +1,44 @@
+"use client";
+
+import { useState } from "react";
+
+interface GenreRollInputProps {
+  onSubmit: (input: string) => void;
+  disabled?: boolean;
+}
+
+export function GenreRollInput({ onSubmit, disabled }: GenreRollInputProps) {
+  const [value, setValue] = useState("");
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = value.trim();
+    if (trimmed.length > 0) {
+      onSubmit(trimmed);
+    }
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex gap-2 w-full max-w-md mx-auto">
+      <label htmlFor="genre-roll-input" className="sr-only">
+        Genres or emotions
+      </label>
+      <input
+        id="genre-roll-input"
+        type="text"
+        value={value}
+        onChange={(e) => setValue(e.target.value)}
+        placeholder="e.g. happy, scary, nostalgic"
+        disabled={disabled}
+        className="flex-1 rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
+      />
+      <button
+        type="submit"
+        disabled={disabled || value.trim().length === 0}
+        className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
+      >
+        Genre Roll!
+      </button>
+    </form>
+  );
+}

+ 33 - 0
src/components/dice/movie-card-content.tsx

@@ -0,0 +1,33 @@
+"use client";
+
+import type { Movie } from "@/types/movie";
+
+interface MovieCardContentProps {
+  movie: Movie;
+}
+
+export function MovieCardContent({ movie }: MovieCardContentProps) {
+  return (
+    <div className="flex items-center gap-4 rounded-xl bg-gray-900/80 p-4 shadow-lg">
+      {movie.poster_path ? (
+        <img
+          src={`https://image.tmdb.org/t/p/w185${movie.poster_path}`}
+          alt={`${movie.title} poster`}
+          className="w-16 h-24 rounded-lg object-cover"
+          loading="lazy"
+        />
+      ) : (
+        <div className="w-16 h-24 rounded-lg bg-gray-700 flex items-center justify-center text-xs text-gray-400">
+          No poster
+        </div>
+      )}
+      <div className="flex-1 min-w-0">
+        <p className="text-lg font-bold text-white truncate">{movie.title}</p>
+        <p className="text-sm text-gray-400">{movie.year}</p>
+        {movie.genres.length > 0 && (
+          <p className="text-xs text-gray-500 mt-1 truncate">{movie.genres.join(", ")}</p>
+        )}
+      </div>
+    </div>
+  );
+}

+ 122 - 0
src/components/dice/roll-animation.tsx

@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+import type { Movie } from "@/types/movie";
+import type { RollState } from "@/hooks/use-roll";
+
+interface RollAnimationProps {
+  rollState: RollState;
+  result: Movie | null;
+  pool: Movie[];
+}
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeToReducedMotion(callback: () => void) {
+  const mq = window.matchMedia(REDUCED_MOTION_QUERY);
+  mq.addEventListener("change", callback);
+  return () => mq.removeEventListener("change", callback);
+}
+
+function getReducedMotionSnapshot() {
+  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
+}
+
+function getReducedMotionServerSnapshot() {
+  return false;
+}
+
+function usePrefersReducedMotion(): boolean {
+  return useSyncExternalStore(
+    subscribeToReducedMotion,
+    getReducedMotionSnapshot,
+    getReducedMotionServerSnapshot,
+  );
+}
+
+const MAX_VISIBLE = 8;
+
+export function RollAnimation({ rollState, result, pool }: RollAnimationProps) {
+  const prefersReducedMotion = usePrefersReducedMotion();
+  const [eliminatedCount, setEliminatedCount] = useState(0);
+
+  const visiblePool = pool.slice(0, MAX_VISIBLE);
+  const totalToEliminate = visiblePool.length - 1;
+
+  useEffect(() => {
+    if (rollState !== "rolling") {
+      setEliminatedCount(0);
+      return;
+    }
+
+    if (prefersReducedMotion || totalToEliminate <= 0) return;
+
+    const intervalMs = 2000 / Math.max(totalToEliminate, 1);
+    let count = 0;
+    const timer = setInterval(() => {
+      count++;
+      setEliminatedCount(count);
+      if (count >= totalToEliminate) clearInterval(timer);
+    }, intervalMs);
+
+    return () => clearInterval(timer);
+  }, [rollState, prefersReducedMotion, totalToEliminate]);
+
+  if (rollState === "idle") return null;
+
+  if (prefersReducedMotion) {
+    if (!result) return null;
+    return (
+      <div className="flex justify-center py-6">
+        <div
+          className={`transition-opacity duration-500 ${rollState === "complete" ? "opacity-100" : "opacity-0"}`}
+        >
+          <PosterThumbnail movie={result} showAlt />
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-wrap justify-center gap-3 py-6" aria-hidden="true">
+      {visiblePool.map((movie, i) => {
+        const isWinner = result && movie.id === result.id;
+        const isEliminated = !isWinner && i < eliminatedCount;
+        const isRevealed = rollState === "complete" && isWinner;
+
+        return (
+          <div
+            key={movie.id}
+            className={`
+              relative w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
+              transition-all duration-500 ease-out
+              ${isEliminated ? "opacity-0 scale-50 rotate-45 translate-y-8" : ""}
+              ${isRevealed ? "scale-110 ring-4 ring-red-500 shadow-xl" : ""}
+              ${rollState === "rolling" && !isEliminated ? "animate-pulse" : ""}
+            `}
+          >
+            <PosterThumbnail movie={movie} />
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+function PosterThumbnail({ movie, showAlt }: { movie: Movie; showAlt?: boolean }) {
+  if (movie.poster_path) {
+    return (
+      <img
+        src={`https://image.tmdb.org/t/p/w185${movie.poster_path}`}
+        alt={showAlt ? `${movie.title} poster` : ""}
+        className="w-full h-full object-cover"
+        loading="lazy"
+      />
+    );
+  }
+  return (
+    <div className="w-full h-full bg-gray-700 flex items-center justify-center text-xs text-gray-400 p-1 text-center">
+      {movie.title}
+    </div>
+  );
+}

+ 25 - 0
src/components/dice/roll-button.tsx

@@ -0,0 +1,25 @@
+"use client";
+
+import type { RollState } from "@/hooks/use-roll";
+
+interface RollButtonProps {
+  onRoll: () => void;
+  rollState: RollState;
+}
+
+export function RollButton({ onRoll, rollState }: RollButtonProps) {
+  const isRolling = rollState === "rolling";
+  const label = rollState === "complete" ? "Re-roll!" : "Roll the Dice!";
+
+  return (
+    <button
+      type="button"
+      onClick={onRoll}
+      disabled={isRolling}
+      aria-label={isRolling ? "Rolling..." : label}
+      className="w-full max-w-xs mx-auto block rounded-2xl bg-red-600 px-8 py-4 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed sm:text-xl"
+    >
+      {isRolling ? "Rolling..." : label}
+    </button>
+  );
+}

+ 34 - 0
src/components/dice/roll-result.tsx

@@ -0,0 +1,34 @@
+"use client";
+
+import type { Movie } from "@/types/movie";
+import type { RollState } from "@/hooks/use-roll";
+import { MovieCardContent } from "./movie-card-content";
+
+interface RollResultProps {
+  result: Movie | null;
+  rollState: RollState;
+  onTap?: () => void;
+}
+
+export function RollResult({ result, rollState, onTap }: RollResultProps) {
+  if (rollState !== "complete" || !result) return null;
+
+  return (
+    <div aria-live="polite" aria-atomic="true" className="mt-4">
+      <span className="sr-only">
+        Roll result: {result.title} ({result.year})
+      </span>
+      {onTap ? (
+        <button
+          type="button"
+          onClick={onTap}
+          className="w-full text-left hover:ring-2 hover:ring-red-400 rounded-xl transition-shadow"
+        >
+          <MovieCardContent movie={result} />
+        </button>
+      ) : (
+        <MovieCardContent movie={result} />
+      )}
+    </div>
+  );
+}

+ 16 - 0
src/components/dice/teaser-card.tsx

@@ -0,0 +1,16 @@
+"use client";
+
+import type { Movie } from "@/types/movie";
+import { MovieCardContent } from "./movie-card-content";
+
+interface TeaserCardProps {
+  movie: Movie;
+}
+
+export function TeaserCard({ movie }: TeaserCardProps) {
+  return (
+    <div aria-live="polite" aria-atomic="true">
+      <MovieCardContent movie={movie} />
+    </div>
+  );
+}

+ 82 - 0
src/components/groups/create-group-form.tsx

@@ -0,0 +1,82 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
+
+export function CreateGroupForm() {
+  const [name, setName] = useState("");
+  const queryClient = useQueryClient();
+
+  const createGroup = useMutation({
+    mutationFn: async (groupName: string) => {
+      const res = await fetch("/api/groups", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name: groupName }),
+      });
+      if (!res.ok) {
+        const data = await res.json();
+        throw new Error(data.error || "Failed to create group");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups"] });
+      setName("");
+    },
+  });
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = name.trim();
+    if (!trimmed) return;
+    createGroup.mutate(trimmed);
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
+      <div>
+        <label htmlFor="group-name" className="block text-sm font-medium mb-1">
+          Group Name
+        </label>
+        <input
+          id="group-name"
+          type="text"
+          value={name}
+          onChange={(e) => setName(e.target.value)}
+          maxLength={GROUP_NAME_MAX_LENGTH}
+          placeholder="e.g., Movie Night Crew"
+          required
+          className="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
+        />
+        <p className="mt-1 text-xs text-gray-500">
+          {name.length}/{GROUP_NAME_MAX_LENGTH} characters
+        </p>
+      </div>
+
+      {createGroup.error && (
+        <p className="text-sm text-red-500" role="alert">
+          {createGroup.error.message}
+        </p>
+      )}
+
+      {createGroup.isSuccess && createGroup.data?.group && (
+        <div className="rounded-md bg-green-50 p-3 dark:bg-green-900/20" role="alert">
+          <p className="text-sm font-medium text-green-800 dark:text-green-200">
+            Group created! Invite code:{" "}
+            <span className="font-mono font-bold">{createGroup.data.group.invite_code}</span>
+          </p>
+        </div>
+      )}
+
+      <button
+        type="submit"
+        disabled={createGroup.isPending || !name.trim()}
+        className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+      >
+        {createGroup.isPending ? "Creating..." : "Create Group"}
+      </button>
+    </form>
+  );
+}

+ 79 - 0
src/components/groups/join-form.tsx

@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+export function JoinForm() {
+  const [code, setCode] = useState("");
+  const queryClient = useQueryClient();
+
+  const joinGroup = useMutation({
+    mutationFn: async (inviteCode: string) => {
+      const res = await fetch("/api/groups/join", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ invite_code: inviteCode }),
+      });
+      if (!res.ok) {
+        const data = await res.json();
+        throw new Error(data.error || "Failed to join group");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups"] });
+      setCode("");
+    },
+  });
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = code.trim();
+    if (!trimmed) return;
+    joinGroup.mutate(trimmed);
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
+      <div>
+        <label htmlFor="invite-code" className="block text-sm font-medium mb-1">
+          Invite Code
+        </label>
+        <input
+          id="invite-code"
+          type="text"
+          value={code}
+          onChange={(e) => setCode(e.target.value)}
+          placeholder="e.g., WOLF-MOON"
+          required
+          className="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm font-mono uppercase placeholder:text-gray-500 placeholder:normal-case focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
+        />
+        <p className="mt-1 text-xs text-gray-500">
+          Enter the invite code shared with you (case-insensitive)
+        </p>
+      </div>
+
+      {joinGroup.error && (
+        <p className="text-sm text-red-500" role="alert">
+          {joinGroup.error.message}
+        </p>
+      )}
+
+      {joinGroup.isSuccess && joinGroup.data?.group && (
+        <div className="rounded-md bg-green-50 p-3 dark:bg-green-900/20" role="alert">
+          <p className="text-sm font-medium text-green-800 dark:text-green-200">
+            Joined &ldquo;{joinGroup.data.group.name}&rdquo; successfully!
+          </p>
+        </div>
+      )}
+
+      <button
+        type="submit"
+        disabled={joinGroup.isPending || !code.trim()}
+        className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+      >
+        {joinGroup.isPending ? "Joining..." : "Join Group"}
+      </button>
+    </form>
+  );
+}

+ 111 - 0
src/components/groups/member-list.tsx

@@ -0,0 +1,111 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+interface Member {
+  user_id: string;
+  role: "admin" | "member";
+  joined_at: string;
+  users: { display_name: string; avatar_color: string | null } | null;
+}
+
+interface MembersResponse {
+  members: Member[];
+  currentUserRole: "admin" | "member";
+}
+
+export function MemberList({
+  groupId,
+  onTransferRequest,
+}: {
+  groupId: string;
+  onTransferRequest: (userId: string, displayName: string) => void;
+}) {
+  const queryClient = useQueryClient();
+
+  const { data, isLoading, error } = useQuery<MembersResponse>({
+    queryKey: ["groups", groupId, "members"],
+    queryFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}/members`);
+      if (!res.ok) throw new Error("Failed to fetch members");
+      return res.json();
+    },
+    staleTime: 30_000,
+  });
+
+  const removeMember = useMutation({
+    mutationFn: async (userId: string) => {
+      const res = await fetch(`/api/groups/${groupId}/members`, {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ user_id: userId }),
+      });
+      if (!res.ok) {
+        const data = await res.json();
+        throw new Error(data.error || "Failed to remove member");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups", groupId, "members"] });
+    },
+  });
+
+  if (isLoading) return <p className="text-sm text-gray-500">Loading members...</p>;
+  if (error) return <p className="text-sm text-red-500">Failed to load members</p>;
+  if (!data) return null;
+
+  const isAdmin = data.currentUserRole === "admin";
+
+  return (
+    <div>
+      <h3 className="text-sm font-medium mb-2">Members ({data.members.length})</h3>
+      <ul className="space-y-2">
+        {data.members.map((member) => (
+          <li
+            key={member.user_id}
+            className="flex items-center justify-between rounded-md border border-gray-200 p-2 dark:border-gray-700"
+          >
+            <div className="flex items-center gap-2">
+              <span
+                className="inline-block h-6 w-6 rounded-full"
+                style={{ backgroundColor: member.users?.avatar_color ?? "#6b7280" }}
+                aria-hidden="true"
+              />
+              <span className="text-sm">{member.users?.display_name ?? "Unknown"}</span>
+              {member.role === "admin" && (
+                <span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
+                  Admin
+                </span>
+              )}
+            </div>
+            {isAdmin && member.role !== "admin" && (
+              <div className="flex gap-1">
+                <button
+                  onClick={() =>
+                    onTransferRequest(member.user_id, member.users?.display_name ?? "this member")
+                  }
+                  className="rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20"
+                >
+                  Make Admin
+                </button>
+                <button
+                  onClick={() => removeMember.mutate(member.user_id)}
+                  disabled={removeMember.isPending}
+                  className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900/20"
+                >
+                  Remove
+                </button>
+              </div>
+            )}
+          </li>
+        ))}
+      </ul>
+      {removeMember.error && (
+        <p className="mt-2 text-sm text-red-500" role="alert">
+          {removeMember.error.message}
+        </p>
+      )}
+    </div>
+  );
+}

+ 240 - 0
src/components/groups/settings-panel.tsx

@@ -0,0 +1,240 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { MemberList } from "./member-list";
+import { TransferOwnershipModal } from "./transfer-ownership-modal";
+import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
+
+interface GroupData {
+  group: {
+    id: string;
+    name: string;
+    invite_code: string;
+    created_by: string;
+    created_at: string;
+  };
+  role: "admin" | "member";
+}
+
+export function SettingsPanel({ groupId }: { groupId: string }) {
+  const queryClient = useQueryClient();
+  const [newName, setNewName] = useState("");
+  const [copied, setCopied] = useState(false);
+  const [transferTarget, setTransferTarget] = useState<{
+    userId: string;
+    displayName: string;
+  } | null>(null);
+
+  const { data, isLoading, error } = useQuery<GroupData>({
+    queryKey: ["groups", groupId],
+    queryFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}`);
+      if (!res.ok) throw new Error("Failed to fetch group");
+      return res.json();
+    },
+    staleTime: 30_000,
+  });
+
+  const rename = useMutation({
+    mutationFn: async (name: string) => {
+      const res = await fetch(`/api/groups/${groupId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name }),
+      });
+      if (!res.ok) {
+        const body = await res.json();
+        throw new Error(body.error || "Failed to rename");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
+      setNewName("");
+    },
+  });
+
+  const regenerateCode = useMutation({
+    mutationFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}/invite`, {
+        method: "POST",
+      });
+      if (!res.ok) {
+        const body = await res.json();
+        throw new Error(body.error || "Failed to regenerate code");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
+    },
+  });
+
+  const deleteGroup = useMutation({
+    mutationFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}`, { method: "DELETE" });
+      if (!res.ok) {
+        const body = await res.json();
+        throw new Error(body.error || "Failed to delete group");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups"] });
+    },
+  });
+
+  const leaveGroup = useMutation({
+    mutationFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}/leave`, {
+        method: "POST",
+      });
+      if (!res.ok) {
+        const body = await res.json();
+        throw new Error(body.error || "Failed to leave group");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups"] });
+    },
+  });
+
+  const inviteCode = data?.group.invite_code;
+  const handleCopyCode = useCallback(async () => {
+    if (!inviteCode) return;
+    try {
+      await navigator.clipboard.writeText(inviteCode);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // Fallback: select text for manual copy
+    }
+  }, [inviteCode]);
+
+  const handleTransferRequest = useCallback((userId: string, displayName: string) => {
+    setTransferTarget({ userId, displayName });
+  }, []);
+
+  if (isLoading) return <p className="text-sm text-gray-500">Loading...</p>;
+  if (error) return <p className="text-sm text-red-500">Failed to load group</p>;
+  if (!data) return null;
+
+  const isAdmin = data.role === "admin";
+
+  return (
+    <div className="space-y-6">
+      {/* Invite Code */}
+      <div>
+        <h3 className="text-sm font-medium mb-2">Invite Code</h3>
+        <div className="flex items-center gap-2">
+          <code className="rounded bg-gray-100 px-3 py-2 font-mono text-lg dark:bg-gray-800">
+            {data.group.invite_code}
+          </code>
+          <button
+            onClick={handleCopyCode}
+            className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
+            aria-label="Copy invite code"
+          >
+            {copied ? "Copied!" : "Copy"}
+          </button>
+          {isAdmin && (
+            <button
+              onClick={() => regenerateCode.mutate()}
+              disabled={regenerateCode.isPending}
+              className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
+            >
+              {regenerateCode.isPending ? "..." : "Regenerate"}
+            </button>
+          )}
+        </div>
+        {regenerateCode.error && (
+          <p className="mt-1 text-sm text-red-500">{regenerateCode.error.message}</p>
+        )}
+      </div>
+
+      {/* Rename (admin only) */}
+      {isAdmin && (
+        <div>
+          <h3 className="text-sm font-medium mb-2">Rename Group</h3>
+          <form
+            onSubmit={(e) => {
+              e.preventDefault();
+              const trimmed = newName.trim();
+              if (trimmed) rename.mutate(trimmed);
+            }}
+            className="flex gap-2"
+          >
+            <input
+              type="text"
+              value={newName}
+              onChange={(e) => setNewName(e.target.value)}
+              maxLength={GROUP_NAME_MAX_LENGTH}
+              placeholder={data.group.name}
+              className="flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
+            />
+            <button
+              type="submit"
+              disabled={rename.isPending || !newName.trim()}
+              className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
+            >
+              {rename.isPending ? "..." : "Rename"}
+            </button>
+          </form>
+          {rename.error && <p className="mt-1 text-sm text-red-500">{rename.error.message}</p>}
+        </div>
+      )}
+
+      {/* Members */}
+      <MemberList groupId={groupId} onTransferRequest={handleTransferRequest} />
+
+      {/* Leave / Delete */}
+      <div className="border-t border-gray-200 pt-4 dark:border-gray-700">
+        {isAdmin ? (
+          <div className="space-y-2">
+            <button
+              onClick={() => {
+                if (confirm("Are you sure you want to delete this group? This cannot be undone.")) {
+                  deleteGroup.mutate();
+                }
+              }}
+              disabled={deleteGroup.isPending}
+              className="w-full rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
+            >
+              {deleteGroup.isPending ? "Deleting..." : "Delete Group"}
+            </button>
+            {deleteGroup.error && (
+              <p className="text-sm text-red-500">{deleteGroup.error.message}</p>
+            )}
+          </div>
+        ) : (
+          <div className="space-y-2">
+            <button
+              onClick={() => {
+                if (confirm("Are you sure you want to leave this group?")) {
+                  leaveGroup.mutate();
+                }
+              }}
+              disabled={leaveGroup.isPending}
+              className="w-full rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
+            >
+              {leaveGroup.isPending ? "Leaving..." : "Leave Group"}
+            </button>
+            {leaveGroup.error && <p className="text-sm text-red-500">{leaveGroup.error.message}</p>}
+          </div>
+        )}
+      </div>
+
+      {/* Transfer modal */}
+      {transferTarget && (
+        <TransferOwnershipModal
+          groupId={groupId}
+          targetUserId={transferTarget.userId}
+          targetDisplayName={transferTarget.displayName}
+          onClose={() => setTransferTarget(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 81 - 0
src/components/groups/transfer-ownership-modal.tsx

@@ -0,0 +1,81 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+interface TransferOwnershipModalProps {
+  groupId: string;
+  targetUserId: string;
+  targetDisplayName: string;
+  onClose: () => void;
+}
+
+export function TransferOwnershipModal({
+  groupId,
+  targetUserId,
+  targetDisplayName,
+  onClose,
+}: TransferOwnershipModalProps) {
+  const queryClient = useQueryClient();
+
+  const transfer = useMutation({
+    mutationFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}/transfer`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ new_admin_id: targetUserId }),
+      });
+      if (!res.ok) {
+        const data = await res.json();
+        throw new Error(data.error || "Failed to transfer ownership");
+      }
+      return res.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups", groupId] });
+      queryClient.invalidateQueries({ queryKey: ["groups", groupId, "members"] });
+      onClose();
+    },
+  });
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="transfer-title"
+    >
+      <div className="mx-4 w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
+        <h2 id="transfer-title" className="text-lg font-semibold mb-2">
+          Transfer Admin Role
+        </h2>
+        <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
+          Are you sure you want to make <strong>{targetDisplayName}</strong> the admin? You will
+          become a regular member.
+        </p>
+
+        {transfer.error && (
+          <p className="mb-3 text-sm text-red-500" role="alert">
+            {transfer.error.message}
+          </p>
+        )}
+
+        <div className="flex gap-3 justify-end">
+          <button
+            onClick={onClose}
+            disabled={transfer.isPending}
+            className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
+          >
+            Cancel
+          </button>
+          <button
+            onClick={() => transfer.mutate()}
+            disabled={transfer.isPending}
+            className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
+          >
+            {transfer.isPending ? "Transferring..." : "Confirm Transfer"}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 29 - 0
src/components/home/empty-state.tsx

@@ -0,0 +1,29 @@
+import Link from "next/link";
+
+export function EmptyState() {
+  return (
+    <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
+      <p className="text-5xl mb-4" aria-hidden="true">
+        🎲
+      </p>
+      <h2 className="text-xl font-semibold text-foreground">No lists yet</h2>
+      <p className="mt-2 text-foreground/60 max-w-sm">
+        Create a new list to start adding movies, or join an existing one with an invite code.
+      </p>
+      <div className="mt-6 flex flex-col sm:flex-row gap-3">
+        <Link
+          href="/create"
+          className="inline-flex items-center justify-center rounded-lg bg-foreground text-background px-6 py-3 text-sm font-medium hover:opacity-90 transition-opacity"
+        >
+          Create List
+        </Link>
+        <Link
+          href="/join"
+          className="inline-flex items-center justify-center rounded-lg border border-foreground/20 px-6 py-3 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors"
+        >
+          Join with Code
+        </Link>
+      </div>
+    </div>
+  );
+}

+ 26 - 0
src/components/home/list-card.tsx

@@ -0,0 +1,26 @@
+import Link from "next/link";
+import type { UserGroup } from "@/hooks/use-user-groups";
+
+interface ListCardProps {
+  group: UserGroup;
+}
+
+export function ListCard({ group }: ListCardProps) {
+  return (
+    <Link
+      href={`/list/${group.id}`}
+      className="block rounded-lg border border-foreground/10 bg-foreground/[0.02] p-4 hover:bg-foreground/5 transition-colors"
+    >
+      <div className="flex items-start justify-between gap-2">
+        <h3 className="text-base font-semibold text-foreground truncate">{group.name}</h3>
+        <span
+          className="shrink-0 text-sm text-foreground/60"
+          aria-label={`${group.movie_count} movies`}
+        >
+          🎬 {group.movie_count}
+        </span>
+      </div>
+      <p className="mt-1 text-sm text-foreground/50 truncate">Created by: {group.creator_name}</p>
+    </Link>
+  );
+}

+ 39 - 0
src/components/home/list-grid.tsx

@@ -0,0 +1,39 @@
+"use client";
+
+import { ListCard } from "@/components/home/list-card";
+import { EmptyState } from "@/components/home/empty-state";
+import { useUserGroups } from "@/hooks/use-user-groups";
+
+export function ListGrid() {
+  const { data: groups, isLoading, error } = useUserGroups();
+
+  if (isLoading) {
+    return (
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        {Array.from({ length: 3 }).map((_, i) => (
+          <div key={i} className="h-24 animate-pulse rounded-lg bg-foreground/5" />
+        ))}
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <p className="text-center text-sm text-red-500 py-8">
+        Failed to load lists. Please try again.
+      </p>
+    );
+  }
+
+  if (!groups || groups.length === 0) {
+    return <EmptyState />;
+  }
+
+  return (
+    <div className="grid grid-cols-1 sm:grid-cols-2 gap-4" aria-live="polite">
+      {groups.map((group) => (
+        <ListCard key={group.id} group={group} />
+      ))}
+    </div>
+  );
+}

+ 17 - 0
src/components/landing/about-section.tsx

@@ -0,0 +1,17 @@
+export function AboutSection() {
+  return (
+    <section className="mx-auto max-w-2xl px-4 py-16 text-center">
+      <h2 className="text-3xl font-bold">About MovieDice</h2>
+      <p className="mt-4 text-foreground/70 leading-relaxed">
+        MovieDice solves the age-old problem of group decision paralysis. Build a shared movie
+        watchlist with your friends, roommates, or family, then let the dice decide what to watch.
+        No more endless scrolling, no more arguments. Just tap, roll, and watch.
+      </p>
+      <p className="mt-3 text-foreground/70 leading-relaxed">
+        Create private groups with simple invite codes, add movies from TMDB&apos;s vast database,
+        and keep track of what you&apos;ve watched together. It&apos;s your shared movie list,
+        supercharged with a randomizer.
+      </p>
+    </section>
+  );
+}

+ 119 - 0
src/components/landing/genre-roll-landing.tsx

@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { TMDB_GENRE_MAP, type TMDBMovie } from "@/types/tmdb";
+import { EMOTION_TO_GENRE_MAP, NOSTALGIC_KEYWORDS, NOSTALGIC_YEAR_CUTOFF } from "@/lib/constants";
+import { TeaserCard } from "./teaser-card";
+
+const GENRE_NAME_TO_ID = Object.fromEntries(
+  Object.entries(TMDB_GENRE_MAP).map(([id, name]) => [name.toLowerCase(), Number(id)]),
+);
+
+function parseInput(input: string): { genreIds: number[]; yearCutoff: string | null } {
+  const terms = input
+    .toLowerCase()
+    .split(",")
+    .map((t) => t.trim())
+    .filter(Boolean);
+
+  const genreIds = new Set<number>();
+  let yearCutoff: string | null = null;
+
+  for (const term of terms) {
+    // Check direct genre name match
+    if (GENRE_NAME_TO_ID[term] !== undefined) {
+      genreIds.add(GENRE_NAME_TO_ID[term]);
+      continue;
+    }
+
+    // Check emotion mapping
+    const emotion = EMOTION_TO_GENRE_MAP[term];
+    if (emotion) {
+      if (NOSTALGIC_KEYWORDS.has(term)) {
+        yearCutoff = `${NOSTALGIC_YEAR_CUTOFF}-12-31`;
+      }
+      for (const id of emotion.primary) genreIds.add(id);
+      if (genreIds.size === 0) {
+        for (const id of emotion.secondary) genreIds.add(id);
+      }
+    }
+  }
+
+  return { genreIds: Array.from(genreIds), yearCutoff };
+}
+
+export function GenreRollLanding() {
+  const [input, setInput] = useState("");
+  const [result, setResult] = useState<TMDBMovie | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const handleRoll = async () => {
+    if (loading || !input.trim()) return;
+    setResult(null);
+    setError(null);
+    setLoading(true);
+
+    try {
+      const { genreIds, yearCutoff } = parseInput(input);
+
+      const params = new URLSearchParams();
+      if (genreIds.length > 0) params.set("with_genres", genreIds.join(","));
+      if (yearCutoff) params.set("primary_release_date.lte", yearCutoff);
+
+      const res = await fetch(`/api/tmdb/discover?${params.toString()}`);
+      const data: { results: TMDBMovie[] } = await res.json();
+      const movies = data.results ?? [];
+
+      if (movies.length === 0) {
+        setError("No movies found. Try different genres or moods.");
+        setLoading(false);
+        return;
+      }
+
+      const chosen = movies[Math.floor(Math.random() * movies.length)];
+      setResult(chosen);
+    } catch {
+      setError("Something went wrong. Please try again.");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="flex w-full max-w-md flex-col items-center gap-4 px-4">
+      <label htmlFor="genre-input" className="text-sm font-medium text-foreground/70">
+        Enter genres or moods (comma-separated)
+      </label>
+      <div className="flex w-full gap-2">
+        <input
+          id="genre-input"
+          type="text"
+          value={input}
+          onChange={(e) => setInput(e.target.value)}
+          onKeyDown={(e) => {
+            if (e.key === "Enter") handleRoll();
+          }}
+          placeholder="e.g. comedy, scary, romance"
+          className="flex-1 rounded-lg border border-foreground/20 bg-background px-4 py-2 text-sm placeholder:text-foreground/30 focus:border-foreground/40 focus:outline-none"
+        />
+        <button
+          onClick={handleRoll}
+          disabled={loading || !input.trim()}
+          className="rounded-lg bg-foreground/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-foreground/20 disabled:opacity-50"
+        >
+          {loading ? "Rolling..." : "Genre Roll"}
+        </button>
+      </div>
+
+      <div aria-live="polite" className="min-h-[1px] w-full">
+        {error && <p className="text-center text-sm text-red-500">{error}</p>}
+        {result && (
+          <div className="mt-2 flex justify-center">
+            <TeaserCard movie={result} />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 10 - 0
src/components/landing/hero.tsx

@@ -0,0 +1,10 @@
+export function Hero() {
+  return (
+    <section className="flex flex-col items-center px-4 pt-16 pb-8 text-center">
+      <h1 className="text-5xl font-bold tracking-tight sm:text-6xl md:text-7xl">MovieDice</h1>
+      <p className="mt-4 max-w-md text-lg text-foreground/60 sm:text-xl">
+        One shared list. One button to decide. No arguments.
+      </p>
+    </section>
+  );
+}

+ 45 - 0
src/components/landing/how-it-works.tsx

@@ -0,0 +1,45 @@
+const STEPS = [
+  {
+    number: 1,
+    title: "Create a list",
+    description:
+      "Start a private group and share a simple invite code with your friends. No accounts, no emails, no friction.",
+    align: "left" as const,
+  },
+  {
+    number: 2,
+    title: "Add movies",
+    description:
+      "Search TMDB's massive database and add movies to your shared watchlist. Everyone in the group can contribute.",
+    align: "right" as const,
+  },
+  {
+    number: 3,
+    title: "Roll the dice",
+    description:
+      "Can't decide what to watch? Hit the button and let MovieDice pick for you. Fair, random, no arguments.",
+    align: "left" as const,
+  },
+];
+
+export function HowItWorks() {
+  return (
+    <section className="mx-auto max-w-3xl px-4 py-16">
+      <h2 className="mb-12 text-center text-3xl font-bold">How It Works</h2>
+      <div className="flex flex-col gap-12">
+        {STEPS.map((step) => (
+          <div
+            key={step.number}
+            className={`flex flex-col ${step.align === "right" ? "items-end text-right" : "items-start text-left"}`}
+          >
+            <div className="max-w-sm">
+              <span className="text-5xl font-bold text-foreground/10">{step.number}</span>
+              <h3 className="mt-1 text-xl font-semibold">{step.title}</h3>
+              <p className="mt-2 text-foreground/60 leading-relaxed">{step.description}</p>
+            </div>
+          </div>
+        ))}
+      </div>
+    </section>
+  );
+}

+ 244 - 0
src/components/landing/reel-animation.tsx

@@ -0,0 +1,244 @@
+"use client";
+
+import { useState, useEffect, useRef, useMemo } from "react";
+import { getTMDBImageUrl } from "@/types/tmdb";
+import type { TMDBMovie } from "@/types/tmdb";
+import { TeaserCard } from "./teaser-card";
+
+interface ReelPoster {
+  tmdb_id: number;
+  poster_path: string;
+  title: string;
+}
+
+const REEL_COUNT = 3;
+const SPIN_DURATION_MS = 4000;
+const STAGGER_MS = 400;
+const ITEM_HEIGHT = 112; // h-28 = 7rem = 112px
+
+function ReelPlaceholder({ label }: { label: string }) {
+  return (
+    <div
+      className="flex h-28 w-20 items-center justify-center rounded-lg bg-foreground/5"
+      role="img"
+      aria-label={label}
+    >
+      <span className="text-2xl text-foreground/20" aria-hidden="true">
+        ?
+      </span>
+    </div>
+  );
+}
+
+function usePrefersReducedMotion(): boolean {
+  return useMemo(
+    () =>
+      typeof window !== "undefined" &&
+      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
+    [],
+  );
+}
+
+type ReelPhase = "idle" | "spinning" | "settled";
+
+function SingleReel({
+  posters,
+  spinning,
+  finalIndex,
+  delayMs,
+  reelIndex,
+}: {
+  posters: ReelPoster[];
+  spinning: boolean;
+  finalIndex: number;
+  delayMs: number;
+  reelIndex: number;
+}) {
+  const [offset, setOffset] = useState(0);
+  const [phase, setPhase] = useState<ReelPhase>("idle");
+  const animRef = useRef<number | null>(null);
+  const prefersReducedMotion = usePrefersReducedMotion();
+
+  const totalHeight = posters.length * ITEM_HEIGHT;
+
+  if (spinning && phase === "idle") {
+    if (prefersReducedMotion) {
+      setOffset(finalIndex * ITEM_HEIGHT);
+      setPhase("settled");
+    } else {
+      setPhase("spinning");
+    }
+  }
+  if (!spinning && phase === "settled") {
+    setPhase("idle");
+  }
+
+  useEffect(() => {
+    if (phase !== "spinning") return;
+
+    let startTime = 0;
+
+    function tick(timestamp: number) {
+      if (!startTime) startTime = timestamp;
+      const elapsed = timestamp - startTime;
+
+      if (elapsed < delayMs) {
+        animRef.current = requestAnimationFrame(tick);
+        return;
+      }
+
+      const spinElapsed = elapsed - delayMs;
+
+      if (spinElapsed >= SPIN_DURATION_MS) {
+        setOffset(finalIndex * ITEM_HEIGHT);
+        setPhase("settled");
+        return;
+      }
+
+      // Deceleration with easeOutCubic
+      const progress = spinElapsed / SPIN_DURATION_MS;
+      const eased = 1 - Math.pow(1 - progress, 3);
+
+      // Spin through multiple full cycles + land on finalIndex
+      const totalSpin = totalHeight * 3 + finalIndex * ITEM_HEIGHT;
+      const currentOffset = eased * totalSpin;
+      setOffset(currentOffset % totalHeight);
+
+      animRef.current = requestAnimationFrame(tick);
+    }
+
+    animRef.current = requestAnimationFrame(tick);
+
+    return () => {
+      if (animRef.current) cancelAnimationFrame(animRef.current);
+    };
+  }, [phase, finalIndex, delayMs, totalHeight]);
+
+  if (phase === "idle") {
+    return <ReelPlaceholder label={`Reel ${reelIndex + 1} ready`} />;
+  }
+
+  return (
+    <div
+      className="relative h-28 w-20 overflow-hidden rounded-lg bg-foreground/5"
+      role="img"
+      aria-label={
+        phase === "settled" ? `Reel ${reelIndex + 1} stopped` : `Reel ${reelIndex + 1} spinning`
+      }
+    >
+      <div className="absolute left-0 w-full" style={{ transform: `translateY(-${offset}px)` }}>
+        {posters.map((poster, i) => {
+          const url = getTMDBImageUrl(poster.poster_path, "reel");
+          return (
+            <div key={`${poster.tmdb_id}-${i}`} className="h-28 w-20 flex-shrink-0">
+              {url && (
+                /* eslint-disable-next-line @next/next/no-img-element */
+                <img
+                  src={url}
+                  alt=""
+                  aria-hidden="true"
+                  loading="lazy"
+                  className="h-full w-full object-cover"
+                />
+              )}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+export function ReelAnimation() {
+  const [posters, setPosters] = useState<ReelPoster[]>([]);
+  const [spinning, setSpinning] = useState(false);
+  const [result, setResult] = useState<TMDBMovie | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [finalIndices, setFinalIndices] = useState([0, 0, 0]);
+
+  useEffect(() => {
+    fetch("/api/tmdb/reel-posters")
+      .then((res) => res.json())
+      .then((data: { posters: ReelPoster[] }) => {
+        if (data.posters?.length) setPosters(data.posters);
+      })
+      .catch(() => {});
+  }, []);
+
+  const handleRoll = async () => {
+    if (spinning || loading) return;
+    setResult(null);
+    setLoading(true);
+
+    try {
+      const res = await fetch("/api/tmdb/popular");
+      const data: { results: TMDBMovie[] } = await res.json();
+      const movies = data.results ?? [];
+
+      if (movies.length === 0) {
+        setLoading(false);
+        return;
+      }
+
+      const chosen = movies[Math.floor(Math.random() * movies.length)];
+
+      if (posters.length > 0) {
+        setFinalIndices([
+          Math.floor(Math.random() * posters.length),
+          Math.floor(Math.random() * posters.length),
+          Math.floor(Math.random() * posters.length),
+        ]);
+        setSpinning(true);
+
+        const maxDuration = SPIN_DURATION_MS + STAGGER_MS * (REEL_COUNT - 1) + 200;
+        setTimeout(() => {
+          setSpinning(false);
+          setResult(chosen);
+          setLoading(false);
+        }, maxDuration);
+      } else {
+        setResult(chosen);
+        setLoading(false);
+      }
+    } catch {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="flex flex-col items-center gap-6">
+      <div className="flex gap-3" aria-label="Slot machine reels">
+        {posters.length > 0
+          ? Array.from({ length: REEL_COUNT }).map((_, i) => (
+              <SingleReel
+                key={i}
+                posters={posters}
+                spinning={spinning}
+                finalIndex={finalIndices[i]}
+                delayMs={i * STAGGER_MS}
+                reelIndex={i}
+              />
+            ))
+          : Array.from({ length: REEL_COUNT }).map((_, i) => (
+              <ReelPlaceholder key={i} label={`Reel ${i + 1} ready`} />
+            ))}
+      </div>
+
+      <button
+        onClick={handleRoll}
+        disabled={spinning || loading}
+        className="rounded-xl bg-foreground px-8 py-3 text-lg font-semibold text-background transition-opacity hover:opacity-90 disabled:opacity-50"
+      >
+        {spinning ? "Rolling..." : "Roll the Dice"}
+      </button>
+
+      <div aria-live="polite" className="min-h-[1px]">
+        {result && (
+          <div className="mt-4 flex justify-center">
+            <TeaserCard movie={result} />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 49 - 0
src/components/landing/teaser-card.tsx

@@ -0,0 +1,49 @@
+import { getTMDBImageUrl, TMDB_GENRE_MAP } from "@/types/tmdb";
+import type { TMDBMovie } from "@/types/tmdb";
+
+interface TeaserCardProps {
+  movie: TMDBMovie;
+}
+
+export function TeaserCard({ movie }: TeaserCardProps) {
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "grid");
+  const genres = movie.genre_ids
+    .map((id) => TMDB_GENRE_MAP[id])
+    .filter(Boolean)
+    .slice(0, 3);
+  const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
+
+  return (
+    <div className="flex w-full max-w-xs flex-col items-center rounded-xl bg-foreground/5 p-4">
+      {posterUrl ? (
+        /* eslint-disable-next-line @next/next/no-img-element */
+        <img
+          src={posterUrl}
+          alt={`Movie poster for ${movie.title}`}
+          loading="lazy"
+          className="h-auto w-48 rounded-lg"
+        />
+      ) : (
+        <div className="flex h-72 w-48 items-center justify-center rounded-lg bg-foreground/10 text-foreground/40">
+          No poster
+        </div>
+      )}
+      <h3 className="mt-3 text-center text-lg font-semibold">
+        {movie.title}
+        {year && <span className="ml-1 text-sm font-normal text-foreground/50">({year})</span>}
+      </h3>
+      {genres.length > 0 && (
+        <div className="mt-2 flex flex-wrap justify-center gap-1.5">
+          {genres.map((genre) => (
+            <span
+              key={genre}
+              className="rounded-full bg-foreground/10 px-2.5 py-0.5 text-xs text-foreground/70"
+            >
+              {genre}
+            </span>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 49 - 0
src/components/movies/delete-button.tsx

@@ -0,0 +1,49 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+interface DeleteButtonProps {
+  isPending: boolean;
+  onDelete: () => void;
+}
+
+export function DeleteButton({ isPending, onDelete }: DeleteButtonProps) {
+  const [confirming, setConfirming] = useState(false);
+  const buttonRef = useRef<HTMLButtonElement>(null);
+
+  const handleClick = useCallback(() => {
+    if (confirming) {
+      onDelete();
+      setConfirming(false);
+    } else {
+      setConfirming(true);
+    }
+  }, [confirming, onDelete]);
+
+  useEffect(() => {
+    if (!confirming) return;
+
+    function handleOutsideClick(e: MouseEvent) {
+      if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) {
+        setConfirming(false);
+      }
+    }
+
+    document.addEventListener("click", handleOutsideClick, true);
+    return () => document.removeEventListener("click", handleOutsideClick, true);
+  }, [confirming]);
+
+  return (
+    <button
+      ref={buttonRef}
+      type="button"
+      onClick={handleClick}
+      disabled={isPending}
+      className={`mt-4 min-h-[44px] min-w-[44px] rounded-lg px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-950 ${
+        confirming ? "animate-shake" : ""
+      }`}
+    >
+      {isPending ? "Deleting..." : confirming ? "Click to confirm delete" : "Delete"}
+    </button>
+  );
+}

+ 151 - 0
src/components/movies/expanded-panel.tsx

@@ -0,0 +1,151 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import type { Database } from "@/types/database";
+import { getTMDBImageUrl } from "@/types/tmdb";
+import { useToggleWatched } from "@/hooks/use-toggle-watched";
+import { useDeleteMovie } from "@/hooks/use-delete-movie";
+import { GenreTag } from "./genre-tag";
+import { WatchedButton } from "./watched-button";
+import { TrailerButton } from "./trailer-button";
+import { DeleteButton } from "./delete-button";
+
+type Movie = Database["public"]["Tables"]["movies"]["Row"];
+
+interface ExpandedPanelProps {
+  movie: Movie;
+  addedByName: string | null;
+  selectedGenre: string | null;
+  onGenreSelect: (genre: string) => void;
+  onClose: () => void;
+}
+
+export function ExpandedPanel({
+  movie,
+  addedByName,
+  selectedGenre,
+  onGenreSelect,
+  onClose,
+}: ExpandedPanelProps) {
+  const panelRef = useRef<HTMLDivElement>(null);
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
+
+  const toggleWatched = useToggleWatched(movie.group_id);
+  const deleteMovie = useDeleteMovie(movie.group_id);
+
+  useEffect(() => {
+    panelRef.current?.focus();
+  }, []);
+
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === "Escape") {
+        onClose();
+      }
+    }
+
+    document.addEventListener("keydown", handleKeyDown);
+    return () => document.removeEventListener("keydown", handleKeyDown);
+  }, [onClose]);
+
+  useEffect(() => {
+    function handleClickOutside(e: MouseEvent) {
+      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
+        onClose();
+      }
+    }
+
+    // Delay to prevent the opening click from immediately closing the panel
+    const timer = setTimeout(() => {
+      document.addEventListener("click", handleClickOutside);
+    }, 0);
+
+    return () => {
+      clearTimeout(timer);
+      document.removeEventListener("click", handleClickOutside);
+    };
+  }, [onClose]);
+
+  return (
+    <div
+      ref={panelRef}
+      role="region"
+      aria-label={`Details for ${movie.title}`}
+      tabIndex={-1}
+      className="col-span-full border-y border-gray-200 bg-gray-50 px-4 py-6 outline-none dark:border-gray-700 dark:bg-gray-900"
+    >
+      <div className="mx-auto flex max-w-2xl flex-col items-center gap-4">
+        <div className="flex w-full justify-end">
+          <button
+            type="button"
+            onClick={onClose}
+            className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
+            aria-label="Close panel"
+          >
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth={2}
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              className="h-5 w-5"
+              aria-hidden="true"
+            >
+              <line x1="18" y1="6" x2="6" y2="18" />
+              <line x1="6" y1="6" x2="18" y2="18" />
+            </svg>
+          </button>
+        </div>
+
+        {posterUrl && (
+          <img
+            src={posterUrl}
+            alt={`Movie poster for ${movie.title}`}
+            loading="lazy"
+            className="w-full max-w-xs rounded-lg shadow-md"
+          />
+        )}
+
+        <h2 className="text-center text-xl font-bold text-foreground">
+          {movie.title} {movie.year > 0 && <span className="font-normal">({movie.year})</span>}
+        </h2>
+
+        {addedByName && (
+          <p className="text-sm text-gray-500 dark:text-gray-400">Added by {addedByName}</p>
+        )}
+
+        {movie.genres.length > 0 && (
+          <div className="flex flex-wrap justify-center gap-2" aria-label="Genre filters">
+            {movie.genres.map((genre) => (
+              <GenreTag
+                key={genre}
+                genre={genre}
+                isActive={selectedGenre === genre}
+                onSelect={onGenreSelect}
+              />
+            ))}
+            <span aria-live="polite" className="sr-only">
+              {selectedGenre ? `Filtered by ${selectedGenre}` : "No genre filter active"}
+            </span>
+          </div>
+        )}
+
+        <div className="flex w-full max-w-xs gap-3">
+          <WatchedButton
+            watched={movie.watched}
+            isPending={toggleWatched.isPending}
+            onToggle={() => toggleWatched.mutate({ movieId: movie.id, watched: !movie.watched })}
+          />
+          <TrailerButton trailerUrl={movie.trailer_url} />
+        </div>
+
+        <DeleteButton
+          isPending={deleteMovie.isPending}
+          onDelete={() => deleteMovie.mutate(movie.id, { onSuccess: () => onClose() })}
+        />
+      </div>
+    </div>
+  );
+}

+ 23 - 0
src/components/movies/genre-tag.tsx

@@ -0,0 +1,23 @@
+"use client";
+
+interface GenreTagProps {
+  genre: string;
+  isActive: boolean;
+  onSelect: (genre: string) => void;
+}
+
+export function GenreTag({ genre, isActive, onSelect }: GenreTagProps) {
+  return (
+    <button
+      type="button"
+      onClick={() => onSelect(genre)}
+      className={`inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full px-3 py-1 text-sm font-medium transition-colors ${
+        isActive
+          ? "bg-blue-600 text-white"
+          : "bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
+      }`}
+    >
+      {genre}
+    </button>
+  );
+}

+ 20 - 0
src/components/movies/trailer-button.tsx

@@ -0,0 +1,20 @@
+"use client";
+
+interface TrailerButtonProps {
+  trailerUrl: string | null;
+}
+
+export function TrailerButton({ trailerUrl }: TrailerButtonProps) {
+  if (!trailerUrl) return null;
+
+  return (
+    <a
+      href={trailerUrl}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="inline-flex min-h-[44px] flex-1 items-center justify-center rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
+    >
+      Trailer
+    </a>
+  );
+}

+ 34 - 0
src/components/movies/watched-button.tsx

@@ -0,0 +1,34 @@
+"use client";
+
+interface WatchedButtonProps {
+  watched: boolean;
+  isPending: boolean;
+  onToggle: () => void;
+}
+
+export function WatchedButton({ watched, isPending, onToggle }: WatchedButtonProps) {
+  return (
+    <>
+      <button
+        type="button"
+        onClick={onToggle}
+        disabled={isPending}
+        className={`inline-flex min-h-[44px] flex-1 items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
+          watched
+            ? "bg-green-600 text-white hover:bg-green-700"
+            : "bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
+        }`}
+        aria-pressed={watched}
+      >
+        {watched ? "Watched" : "Watched It"}
+      </button>
+      <span aria-live="polite" className="sr-only">
+        {isPending
+          ? "Updating watched status"
+          : watched
+            ? "Marked as watched"
+            : "Marked as not watched"}
+      </span>
+    </>
+  );
+}

+ 38 - 0
src/components/shared/empty-state.tsx

@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+
+interface EmptyStateProps {
+  icon?: ReactNode;
+  title: string;
+  description: string;
+  action?: ReactNode;
+}
+
+export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
+  return (
+    <div className="flex flex-col items-center justify-center px-4 py-16 text-center">
+      {icon && <div className="mb-4 text-gray-400">{icon}</div>}
+      <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
+      <p className="mt-1 max-w-sm text-sm text-gray-500">{description}</p>
+      {action && <div className="mt-6">{action}</div>}
+    </div>
+  );
+}
+
+export const emptyStatePresets = {
+  emptyList: {
+    title: "No movies yet",
+    description: "Add a movie to get started with your list.",
+  },
+  noSearchResults: {
+    title: "No movies found",
+    description: "Try a different search term.",
+  },
+  noGenreMatches: {
+    title: "No matches for those genres",
+    description: "Try selecting different genres or clearing your filters.",
+  },
+  newUser: {
+    title: "Welcome!",
+    description: "Create or join a list to get started.",
+  },
+} as const;

+ 63 - 0
src/components/shared/error-boundary.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import * as Sentry from "@sentry/nextjs";
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+interface Props {
+  children: ReactNode;
+  fallback?: ReactNode;
+}
+
+interface State {
+  hasError: boolean;
+}
+
+export class ErrorBoundary extends Component<Props, State> {
+  constructor(props: Props) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError(): State {
+    return { hasError: true };
+  }
+
+  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+    Sentry.captureException(error, {
+      extra: { componentStack: errorInfo.componentStack },
+    });
+  }
+
+  private handleRetry = () => {
+    this.setState({ hasError: false });
+  };
+
+  render() {
+    if (this.state.hasError) {
+      if (this.props.fallback) {
+        return this.props.fallback;
+      }
+
+      return (
+        <div
+          className="flex flex-col items-center justify-center px-4 py-16 text-center"
+          role="alert"
+        >
+          <h2 className="text-lg font-semibold text-gray-900">Something went wrong</h2>
+          <p className="mt-1 text-sm text-gray-500">
+            An unexpected error occurred. Please try again.
+          </p>
+          <button
+            type="button"
+            onClick={this.handleRetry}
+            className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
+          >
+            Try again
+          </button>
+        </div>
+      );
+    }
+
+    return this.props.children;
+  }
+}

+ 32 - 0
src/components/shared/error-message.tsx

@@ -0,0 +1,32 @@
+const errorMessages = {
+  "invalid-invite": {
+    title: "Invalid invite code",
+    description: "The invite code you entered is not valid. Please check and try again.",
+  },
+  "tmdb-failure": {
+    title: "Could not load movie data",
+    description: "We're having trouble reaching the movie database. Please try again later.",
+  },
+  network: {
+    title: "Connection error",
+    description: "Unable to reach the server. Check your internet connection and try again.",
+  },
+} as const;
+
+type ErrorType = keyof typeof errorMessages;
+
+interface ErrorMessageProps {
+  type: ErrorType;
+  message?: string;
+}
+
+export function ErrorMessage({ type, message }: ErrorMessageProps) {
+  const preset = errorMessages[type];
+
+  return (
+    <div className="rounded-md border border-red-200 bg-red-50 px-4 py-3" role="alert">
+      <p className="text-sm font-medium text-red-800">{preset.title}</p>
+      <p className="mt-1 text-sm text-red-700">{message ?? preset.description}</p>
+    </div>
+  );
+}

+ 18 - 0
src/components/shared/loading-spinner.tsx

@@ -0,0 +1,18 @@
+const sizes = {
+  sm: "h-4 w-4 border-2",
+  md: "h-8 w-8 border-2",
+  lg: "h-12 w-12 border-3",
+} as const;
+
+type SpinnerSize = keyof typeof sizes;
+
+export function LoadingSpinner({ size = "md" }: { size?: SpinnerSize }) {
+  return (
+    <div className="flex items-center justify-center p-8" role="status">
+      <div
+        className={`${sizes[size]} animate-spin rounded-full border-gray-300 border-t-blue-600`}
+      />
+      <span className="sr-only">Loading...</span>
+    </div>
+  );
+}

+ 45 - 0
src/components/shared/offline-banner.tsx

@@ -0,0 +1,45 @@
+"use client";
+
+import { useState, useSyncExternalStore } from "react";
+
+function subscribe(callback: () => void) {
+  window.addEventListener("online", callback);
+  window.addEventListener("offline", callback);
+  return () => {
+    window.removeEventListener("online", callback);
+    window.removeEventListener("offline", callback);
+  };
+}
+
+function getSnapshot() {
+  return navigator.onLine;
+}
+
+function getServerSnapshot() {
+  return true;
+}
+
+export function OfflineBanner() {
+  const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+  const [dismissed, setDismissed] = useState(false);
+
+  if (isOnline || dismissed) return null;
+
+  return (
+    <div
+      className="fixed inset-x-0 top-0 z-50 flex items-center justify-between bg-yellow-500 px-4 py-2 text-sm font-medium text-yellow-900"
+      role="alert"
+      aria-live="polite"
+    >
+      <span>You&apos;re offline — some features may be unavailable</span>
+      <button
+        type="button"
+        onClick={() => setDismissed(true)}
+        className="ml-4 rounded px-2 py-0.5 text-yellow-900 hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-700"
+        aria-label="Dismiss offline notice"
+      >
+        &times;
+      </button>
+    </div>
+  );
+}

+ 33 - 0
src/components/shared/tmdb-footer.tsx

@@ -0,0 +1,33 @@
+import Link from "next/link";
+
+export function TMDBFooter() {
+  return (
+    <footer className="mt-auto border-t border-foreground/10 px-4 py-8">
+      <div className="mx-auto flex max-w-3xl flex-col items-center gap-4 text-center">
+        <a
+          href="https://www.themoviedb.org"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-block"
+        >
+          {/* eslint-disable-next-line @next/next/no-img-element */}
+          <img
+            src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_short-8e7b30f73a4020692ccca9c88bafe5dcb6f8a62a4c6bc55cd9ba82bb2cd95f6c.svg"
+            alt="TMDB logo"
+            loading="lazy"
+            className="h-5"
+          />
+        </a>
+        <p className="max-w-md text-xs text-foreground/50">
+          This product uses the TMDB API but is not endorsed or certified by TMDB.
+        </p>
+        <Link
+          href="/privacy"
+          className="text-xs text-foreground/40 underline hover:text-foreground/60"
+        >
+          Privacy Policy
+        </Link>
+      </div>
+    </footer>
+  );
+}

+ 2428 - 0
src/data/word-list.ts

@@ -0,0 +1,2428 @@
+/**
+ * Curated word list for invite code generation.
+ * All words are 3-8 characters, common English, no offensive terms.
+ * Format: WORD-WORD (e.g., WOLF-MOON)
+ */
+export const WORD_LIST = [
+  // Nature & Animals
+  "WOLF",
+  "MOON",
+  "STAR",
+  "BEAR",
+  "HAWK",
+  "DEER",
+  "FISH",
+  "FROG",
+  "LION",
+  "DOVE",
+  "SWAN",
+  "CROW",
+  "WREN",
+  "LARK",
+  "SEAL",
+  "CRAB",
+  "MOTH",
+  "WASP",
+  "MULE",
+  "GOAT",
+  "LAMB",
+  "PUMA",
+  "LYNX",
+  "HARE",
+  "TOAD",
+  "NEWT",
+  "CLAM",
+  "SLUG",
+  "SNAIL",
+  "CRANE",
+  "EAGLE",
+  "RAVEN",
+  "ROBIN",
+  "FINCH",
+  "HERON",
+  "STORK",
+  "OTTER",
+  "WHALE",
+  "SHARK",
+  "TROUT",
+  "CORAL",
+  "GECKO",
+  "VIPER",
+  "COBRA",
+  "BISON",
+  "MOOSE",
+  "PANDA",
+  "KOALA",
+  "LEMUR",
+  "SLOTH",
+  "TIGER",
+  "ZEBRA",
+  "CAMEL",
+  "LLAMA",
+  "HORSE",
+  "PONY",
+  "FALCON",
+  "PARROT",
+  "PELICAN",
+  "PENGUIN",
+  "DOLPHIN",
+  "PANTHER",
+  "LEOPARD",
+  "CHEETAH",
+  "GAZELLE",
+  "BUFFALO",
+  "MUSTANG",
+  "SPARROW",
+  "OSPREY",
+  "CONDOR",
+  "TOUCAN",
+  "MACAW",
+  "FERRET",
+  "BADGER",
+  "WEASEL",
+  "MARTEN",
+  "JACKAL",
+  "COYOTE",
+
+  // Space & Sky
+  "SUN",
+  "SKY",
+  "DAWN",
+  "DUSK",
+  "RAIN",
+  "SNOW",
+  "WIND",
+  "GALE",
+  "MIST",
+  "HAZE",
+  "BOLT",
+  "GLOW",
+  "BEAM",
+  "FLARE",
+  "BLAZE",
+  "COMET",
+  "ORBIT",
+  "SOLAR",
+  "LUNAR",
+  "NOVA",
+  "NEBULA",
+  "PULSAR",
+  "QUASAR",
+  "COSMOS",
+  "GALAXY",
+  "METEOR",
+  "AURORA",
+  "ZENITH",
+  "ARCTIC",
+  "CLOUD",
+  "STORM",
+  "FROST",
+  "BREEZE",
+  "TEMPEST",
+  "THUNDER",
+  "PLASMA",
+  "PRISM",
+  "SPARK",
+  "FLASH",
+  "GLEAM",
+  "SHINE",
+  "RADIANT",
+
+  // Geography & Terrain
+  "HILL",
+  "LAKE",
+  "POND",
+  "CAVE",
+  "PEAK",
+  "VALE",
+  "GLEN",
+  "COVE",
+  "REEF",
+  "DUNE",
+  "MESA",
+  "RIDGE",
+  "CLIFF",
+  "SHORE",
+  "CREEK",
+  "BROOK",
+  "MARSH",
+  "GROVE",
+  "FIELD",
+  "PLAIN",
+  "DELTA",
+  "FJORD",
+  "BASIN",
+  "CANYON",
+  "RAVINE",
+  "SUMMIT",
+  "TUNDRA",
+  "ISLAND",
+  "MEADOW",
+  "VALLEY",
+  "FOREST",
+  "DESERT",
+  "JUNGLE",
+  "OASIS",
+  "LAGOON",
+  "GLACIER",
+  "PLATEAU",
+  "PRAIRIE",
+  "VOLCANO",
+  "HARBOR",
+  "HARBOR",
+  "RAPIDS",
+  "CASCADE",
+  "TERRACE",
+  "GRANITE",
+  "BASALT",
+  "QUARTZ",
+
+  // Trees & Plants
+  "OAK",
+  "ELM",
+  "ASH",
+  "FIG",
+  "YEW",
+  "IVY",
+  "FIR",
+  "PINE",
+  "PALM",
+  "VINE",
+  "FERN",
+  "MOSS",
+  "REED",
+  "SAGE",
+  "MINT",
+  "IRIS",
+  "LILY",
+  "ROSE",
+  "DAISY",
+  "TULIP",
+  "LOTUS",
+  "CEDAR",
+  "MAPLE",
+  "BIRCH",
+  "ASPEN",
+  "WILLOW",
+  "BAMBOO",
+  "CLOVER",
+  "ORCHID",
+  "VIOLET",
+  "POPPY",
+  "ACACIA",
+  "SPRUCE",
+  "CYPRESS",
+  "JUNIPER",
+  "HICKORY",
+  "HEMLOCK",
+  "SEQUOIA",
+  "JASMINE",
+  "BLOSSOM",
+
+  // Colors & Light
+  "RED",
+  "BLUE",
+  "GOLD",
+  "JADE",
+  "ONYX",
+  "RUBY",
+  "OPAL",
+  "AMBER",
+  "IVORY",
+  "CORAL",
+  "PEARL",
+  "TOPAZ",
+  "AZURE",
+  "CYAN",
+  "TEAL",
+  "NAVY",
+  "PLUM",
+  "RUST",
+  "SAND",
+  "SLATE",
+  "STEEL",
+  "BRONZE",
+  "SILVER",
+  "COPPER",
+  "COBALT",
+  "INDIGO",
+  "VIOLET",
+  "SCARLET",
+  "CRIMSON",
+  "EMERALD",
+  "GARNET",
+  "DIAMOND",
+  "CRYSTAL",
+  "OBSIDIAN",
+  "SAPPHIRE",
+
+  // Weather & Elements
+  "FIRE",
+  "ICE",
+  "ASH",
+  "FOG",
+  "DEW",
+  "ARC",
+  "TIDE",
+  "WAVE",
+  "LAVA",
+  "EMBER",
+  "FLAME",
+  "FROST",
+  "STEAM",
+  "SMOKE",
+  "STONE",
+  "IRON",
+  "CLAY",
+  "SALT",
+  "DUST",
+  "SAND",
+  "FLINT",
+  "CHALK",
+  "MARBLE",
+  "GRAVEL",
+  "COBBLE",
+  "PEBBLE",
+  "BOULDER",
+  "MAGMA",
+  "ETHER",
+
+  // Time & Seasons
+  "DAWN",
+  "NOON",
+  "DUSK",
+  "YEAR",
+  "SPRING",
+  "SUMMER",
+  "AUTUMN",
+  "WINTER",
+  "EPOCH",
+  "EQUINOX",
+  "SOLSTICE",
+
+  // Music & Sound
+  "DRUM",
+  "HARP",
+  "BELL",
+  "HORN",
+  "TUNE",
+  "SONG",
+  "CHORD",
+  "RHYTHM",
+  "MELODY",
+  "TEMPO",
+  "BASS",
+  "ALTO",
+  "TENOR",
+  "FLUTE",
+  "VIOLA",
+  "CELLO",
+  "PIANO",
+  "BANJO",
+  "CHIME",
+  "CYMBAL",
+  "BUGLE",
+  "FIDDLE",
+  "GUITAR",
+
+  // Tools & Objects
+  "BOLT",
+  "GEAR",
+  "HELM",
+  "MAST",
+  "SAIL",
+  "KEEL",
+  "OARS",
+  "ARCH",
+  "DOME",
+  "SPIRE",
+  "TOWER",
+  "VAULT",
+  "FORGE",
+  "ANVIL",
+  "BLADE",
+  "LANCE",
+  "SHIELD",
+  "ARROW",
+  "QUIVER",
+  "BANNER",
+  "BEACON",
+  "LANTERN",
+  "COMPASS",
+  "ANCHOR",
+  "CANNON",
+  "HAMMER",
+  "CHISEL",
+  "PISTON",
+  "PULLEY",
+  "LEVER",
+  "PIVOT",
+  "SPOKE",
+  "SPOOL",
+  "CLASP",
+  "BUCKLE",
+  "RIVET",
+  "PRONG",
+  "WEDGE",
+
+  // Food & Drink
+  "PLUM",
+  "PEAR",
+  "LIME",
+  "KIWI",
+  "DATE",
+  "MANGO",
+  "PEACH",
+  "GRAPE",
+  "MELON",
+  "BERRY",
+  "APPLE",
+  "LEMON",
+  "OLIVE",
+  "COCOA",
+  "HONEY",
+  "CREAM",
+  "BREAD",
+  "WHEAT",
+  "GRAIN",
+  "SPICE",
+  "GINGER",
+  "PEPPER",
+  "CLOVE",
+  "NUTMEG",
+  "CIDER",
+  "MOCHA",
+  "CHAI",
+  "BASIL",
+  "THYME",
+  "FENNEL",
+  "CUMIN",
+  "PAPAYA",
+  "CHERRY",
+  "WALNUT",
+  "ALMOND",
+  "CASHEW",
+  "PECAN",
+
+  // Positive Traits
+  "BOLD",
+  "CALM",
+  "KEEN",
+  "FAIR",
+  "WISE",
+  "KIND",
+  "TRUE",
+  "PURE",
+  "WARM",
+  "FIRM",
+  "BRAVE",
+  "NOBLE",
+  "PROUD",
+  "SWIFT",
+  "AGILE",
+  "ALERT",
+  "EAGER",
+  "LOYAL",
+  "MERRY",
+  "VIVID",
+  "WITTY",
+  "HARDY",
+  "STOUT",
+  "DEFT",
+  "GRAND",
+  "PRIME",
+  "VITAL",
+  "LUCID",
+  "BRISK",
+  "CRISP",
+  "FLUID",
+  "SILKY",
+  "SLEEK",
+  "PLUSH",
+  "GENTLE",
+  "MODEST",
+  "JOVIAL",
+  "CANDID",
+  "STURDY",
+  "NIMBLE",
+  "CLEVER",
+  "HONEST",
+  "SERENE",
+  "GALLANT",
+  "VALIANT",
+  "RADIANT",
+
+  // Actions & Movement
+  "RUN",
+  "FLY",
+  "LEAP",
+  "DASH",
+  "SPIN",
+  "SOAR",
+  "DIVE",
+  "ROAM",
+  "TREK",
+  "WADE",
+  "SURF",
+  "GLIDE",
+  "DRIFT",
+  "CLIMB",
+  "MARCH",
+  "CHASE",
+  "DANCE",
+  "SWING",
+  "VAULT",
+  "BOUND",
+  "FLOAT",
+  "HOVER",
+  "SURGE",
+  "SPRINT",
+  "STRIDE",
+  "LAUNCH",
+  "PADDLE",
+  "RAMBLE",
+  "WANDER",
+  "TRAVEL",
+  "EXPLORE",
+  "VENTURE",
+
+  // Crafts & Skills
+  "CRAFT",
+  "BUILD",
+  "CARVE",
+  "WEAVE",
+  "KNIT",
+  "MOLD",
+  "CAST",
+  "FORGE",
+  "TEMPER",
+  "POLISH",
+  "GRIND",
+  "SHAPE",
+  "SCULPT",
+  "SKETCH",
+  "PAINT",
+  "DRAW",
+  "STITCH",
+  "THREAD",
+  "DESIGN",
+  "CREATE",
+  "INVENT",
+
+  // Fabrics & Materials
+  "SILK",
+  "WOOL",
+  "LACE",
+  "FELT",
+  "CORD",
+  "ROPE",
+  "WIRE",
+  "MESH",
+  "TWINE",
+  "CANVAS",
+  "SATIN",
+  "LINEN",
+  "VELVET",
+  "COTTON",
+  "DENIM",
+  "SUEDE",
+  "BURLAP",
+  "FLEECE",
+
+  // Architecture
+  "WALL",
+  "GATE",
+  "ARCH",
+  "PIER",
+  "POST",
+  "RAMP",
+  "STEP",
+  "TILE",
+  "BEAM",
+  "FRAME",
+  "PANEL",
+  "SHELF",
+  "LEDGE",
+  "NICHE",
+  "ALCOVE",
+  "TURRET",
+  "GAZEBO",
+  "PILLAR",
+  "COLUMN",
+  "FACADE",
+  "PARAPET",
+  "TERRACE",
+  "BALCONY",
+
+  // Maritime
+  "BAY",
+  "SEA",
+  "PORT",
+  "DOCK",
+  "HULL",
+  "DECK",
+  "BOW",
+  "STERN",
+  "WHARF",
+  "YACHT",
+  "BARGE",
+  "KAYAK",
+  "CANOE",
+  "RAFT",
+  "FLEET",
+  "VESSEL",
+  "GALLEY",
+  "FRIGATE",
+
+  // Mythology & Fantasy
+  "MYTH",
+  "LORE",
+  "RUNE",
+  "SAGE",
+  "MAGE",
+  "BARD",
+  "WARD",
+  "CHARM",
+  "SPELL",
+  "QUEST",
+  "REALM",
+  "REIGN",
+  "CROWN",
+  "THRONE",
+  "SCEPTER",
+  "DRAGON",
+  "PHOENIX",
+  "GRIFFIN",
+  "SPHINX",
+  "LEGEND",
+  "FABLE",
+  "EPIC",
+
+  // Directions & Position
+  "NORTH",
+  "SOUTH",
+  "EAST",
+  "WEST",
+  "APEX",
+  "EDGE",
+  "CORE",
+  "BASE",
+  "CREST",
+  "FRONT",
+  "FLANK",
+  "CENTER",
+  "ORIGIN",
+
+  // Abstract Concepts
+  "HOPE",
+  "GRIT",
+  "ZEAL",
+  "BOND",
+  "LINK",
+  "PACT",
+  "OATH",
+  "TRUST",
+  "HONOR",
+  "MERIT",
+  "GRACE",
+  "VALOR",
+  "VIGOR",
+  "MIGHT",
+  "GUSTO",
+  "VERVE",
+  "NERVE",
+  "PLUCK",
+  "SPARK",
+  "DRIVE",
+  "FOCUS",
+  "SPIRIT",
+  "HARMONY",
+  "TRIUMPH",
+  "RESOLVE",
+  "COURAGE",
+  "INSIGHT",
+  "LIBERTY",
+  "JUSTICE",
+
+  // Science & Math
+  "ATOM",
+  "CELL",
+  "GENE",
+  "ION",
+  "AMP",
+  "VOLT",
+  "WATT",
+  "HERTZ",
+  "JOULE",
+  "LOGIC",
+  "AXIOM",
+  "PROOF",
+  "RATIO",
+  "THETA",
+  "SIGMA",
+  "DELTA",
+  "OMEGA",
+  "ALPHA",
+  "GAMMA",
+  "BETA",
+  "HELIX",
+  "NEXUS",
+  "MATRIX",
+  "CIPHER",
+  "VECTOR",
+  "PHOTON",
+  "PROTON",
+  "NEURON",
+  "QUANTA",
+
+  // Weather Phenomena
+  "HAIL",
+  "SLEET",
+  "SQUALL",
+  "GUST",
+  "DRAFT",
+  "DRIZZLE",
+  "TORRENT",
+
+  // Textiles & Patterns
+  "PLAID",
+  "CHECK",
+  "STRIPE",
+  "MOTIF",
+  "MOSAIC",
+  "SWIRL",
+  "SPIRAL",
+
+  // Minerals & Gems
+  "AGATE",
+  "BERYL",
+  "JASPER",
+  "FLINT",
+  "MICA",
+  "PYRITE",
+  "ZIRCON",
+
+  // Furniture & Home
+  "LAMP",
+  "DESK",
+  "CHAIR",
+  "TABLE",
+  "BENCH",
+  "STOOL",
+  "EASEL",
+  "CRADLE",
+  "MANTLE",
+  "HEARTH",
+
+  // Vehicles & Travel
+  "CART",
+  "SLED",
+  "RAIL",
+  "COACH",
+  "WAGON",
+  "ROVER",
+  "GLIDER",
+  "CHARIOT",
+
+  // Games & Play
+  "GAME",
+  "PLAY",
+  "DICE",
+  "CARD",
+  "KING",
+  "QUEEN",
+  "KNIGHT",
+  "BISHOP",
+  "ROOK",
+  "PAWN",
+  "JOKER",
+  "TOKEN",
+  "TROPHY",
+  "PUZZLE",
+  "RIDDLE",
+
+  // Body & Health
+  "PALM",
+  "HAND",
+  "FIST",
+  "GRIP",
+  "HEART",
+  "PULSE",
+  "BREATH",
+  "STRIDE",
+  "SPINE",
+  "SINEW",
+  "REFLEX",
+
+  // Land & Farming
+  "FARM",
+  "CROP",
+  "SEED",
+  "ROOT",
+  "STEM",
+  "LEAF",
+  "BARK",
+  "PETAL",
+  "BLOOM",
+  "FRUIT",
+  "HUSK",
+  "HULL",
+  "CHAFF",
+  "STALK",
+  "SPRIG",
+  "SPROUT",
+  "THICKET",
+  "GARDEN",
+  "ORCHARD",
+  "HARVEST",
+
+  // Textural Words
+  "GLOW",
+  "HUM",
+  "BUZZ",
+  "PING",
+  "SNAP",
+  "POP",
+  "TAP",
+  "ZAP",
+  "FIZZ",
+  "WHIP",
+  "CLICK",
+  "CRACK",
+  "THUD",
+  "CLAP",
+  "CLANG",
+  "SWISH",
+  "WHOOSH",
+
+  // Celestial Bodies
+  "MARS",
+  "VENUS",
+  "PLUTO",
+  "TITAN",
+  "ATLAS",
+  "ORION",
+  "LYRA",
+  "DRACO",
+  "HYDRA",
+  "CETUS",
+  "RIGEL",
+  "VEGA",
+  "SIRIUS",
+  "ALTAIR",
+  "CASTOR",
+  "POLLUX",
+
+  // Occupations
+  "SMITH",
+  "MASON",
+  "BAKER",
+  "PILOT",
+  "SCOUT",
+  "GUARD",
+  "RANGER",
+  "ARCHER",
+  "HUNTER",
+  "SAILOR",
+  "PORTER",
+  "HERALD",
+  "SQUIRE",
+  "CADET",
+
+  // Musical Terms
+  "FORTE",
+  "LARGO",
+  "VIVACE",
+  "PRESTO",
+  "ALLEGRO",
+
+  // Dance
+  "WALTZ",
+  "TANGO",
+  "POLKA",
+  "SALSA",
+  "SAMBA",
+  "RUMBA",
+  "SWING",
+
+  // Writing & Books
+  "BOOK",
+  "PAGE",
+  "WORD",
+  "TALE",
+  "POEM",
+  "VERSE",
+  "PROSE",
+  "NOVEL",
+  "STORY",
+  "SCRIPT",
+  "SCROLL",
+  "TOME",
+  "FOLIO",
+  "CANTO",
+  "STANZA",
+  "BALLAD",
+  "SONNET",
+  "MEMOIR",
+
+  // Shapes & Geometry
+  "CUBE",
+  "CONE",
+  "OVAL",
+  "RING",
+  "LOOP",
+  "KNOT",
+  "COIL",
+  "HELIX",
+  "PRISM",
+  "WEDGE",
+  "SPHERE",
+  "CIRCLE",
+
+  // Feelings & States
+  "JOY",
+  "AWE",
+  "ZEN",
+  "BLISS",
+  "PEACE",
+  "PRIDE",
+  "FAITH",
+  "CHEER",
+  "MIRTH",
+  "ELATION",
+  "DELIGHT",
+
+  // Building & Construction
+  "BRICK",
+  "PLANK",
+  "STUD",
+  "JOIST",
+  "RAFTER",
+  "GIRDER",
+  "TRUSS",
+  "MORTAR",
+  "GROUT",
+  "DOWEL",
+  "STRUT",
+
+  // Cooking & Kitchen
+  "BOWL",
+  "FORK",
+  "LADLE",
+  "WHISK",
+  "SPATULA",
+  "SKILLET",
+
+  // Cloth Colors & Dyes
+  "WOAD",
+  "HENNA",
+  "OCHRE",
+  "SIENNA",
+  "UMBER",
+
+  // Landscape Features
+  "KNOLL",
+  "BLUFF",
+  "BUTTE",
+  "GORGE",
+  "LEDGE",
+  "SHOAL",
+  "GULLY",
+  "GROTTO",
+  "HOLLOW",
+
+  // Types of Light
+  "RAY",
+  "LAMP",
+  "GLOW",
+  "TORCH",
+  "FLICKER",
+  "GLIMMER",
+  "SHIMMER",
+  "TWINKLE",
+  "LANTERN",
+
+  // Metals
+  "TIN",
+  "ZINC",
+  "LEAD",
+  "BRASS",
+  "CHROME",
+  "NICKEL",
+  "PEWTER",
+  "ALLOY",
+  "INGOT",
+
+  // Fabric Arts
+  "QUILT",
+  "BRAID",
+  "TASSEL",
+  "FRINGE",
+  "BOBBIN",
+
+  // Heraldry
+  "CREST",
+  "SIGIL",
+  "BLAZON",
+  "EMBLEM",
+  "ENSIGN",
+  "PENNANT",
+
+  // Weather Adjectives
+  "CLEAR",
+  "FAIR",
+  "MILD",
+  "BALMY",
+  "SUNNY",
+  "WINDY",
+  "MUGGY",
+  "CRISP",
+  "HUMID",
+
+  // Ship Parts
+  "PROW",
+  "HELM",
+  "MAST",
+  "BOOM",
+  "CLEAT",
+  "GAFF",
+  "RUDDER",
+  "TILLER",
+
+  // Wilderness
+  "TRAIL",
+  "PATH",
+  "ROUTE",
+  "PASS",
+  "FORD",
+  "BRIDGE",
+  "TUNNEL",
+
+  // Cosmic
+  "VOID",
+  "ABYSS",
+  "FLUX",
+  "DRIFT",
+  "SURGE",
+  "VORTEX",
+
+  // Rare Beautiful Words
+  "HAVEN",
+  "BOWER",
+  "ARBOR",
+  "GLADE",
+  "COPSE",
+  "DELL",
+  "HEATH",
+  "MOOR",
+  "DOWNS",
+  "MARSH",
+  "SWALE",
+  "THATCH",
+  "CROFT",
+  "HAMLET",
+  "SHIRE",
+
+  // Additional Nature
+  "RIVER",
+  "OCEAN",
+  "FALLS",
+  "SPRING",
+  "STREAM",
+  "RAPIDS",
+  "CHANNEL",
+  "STRAIT",
+  "INLET",
+  "ESTUARY",
+
+  // Additional Animals
+  "WOMBAT",
+  "QUAIL",
+  "EGRET",
+  "IBIS",
+  "KITE",
+  "MARTIN",
+  "PIGEON",
+  "MAGPIE",
+  "THRUSH",
+  "ORIOLE",
+  "TANAGER",
+  "BUNTING",
+  "PLOVER",
+  "CURLEW",
+  "AVOCET",
+  "GROUSE",
+  "FALCON",
+  "MERLIN",
+  "KESTREL",
+
+  // Additional Plants
+  "ASTER",
+  "LUPIN",
+  "PANSY",
+  "PEONY",
+  "TANSY",
+  "ALDER",
+  "BEECH",
+  "LARCH",
+  "HAZEL",
+  "ROWAN",
+  "THORN",
+  "BRIAR",
+  "NETTLE",
+  "SORREL",
+  "YARROW",
+  "MALLOW",
+  "LAUREL",
+  "MYRTLE",
+
+  // Landscape
+  "STEPPE",
+  "SAVANNA",
+  "TAIGA",
+
+  // Additional Abstract
+  "ETHOS",
+  "CREED",
+  "TENET",
+  "AXIOM",
+  "MAXIM",
+  "ADAGE",
+  "MOTIF",
+
+  // Music Instruments
+  "OBOE",
+  "LUTE",
+  "FIFE",
+  "GONG",
+  "LYRE",
+  "SITAR",
+  "TABLA",
+
+  // Celestial
+  "ZENITH",
+  "NADIR",
+  "CORONA",
+  "AURORA",
+  "HALO",
+
+  // Architectural Styles
+  "RUSTIC",
+  "GOTHIC",
+  "DORIC",
+  "IONIC",
+  "TUDOR",
+
+  // Sport & Game
+  "MATCH",
+  "ROUND",
+  "SCORE",
+  "RALLY",
+  "SPRINT",
+  "RELAY",
+
+  // Descriptive
+  "VIVID",
+  "STARK",
+  "MUTED",
+  "LUSH",
+  "DENSE",
+  "SPARSE",
+  "VAST",
+  "BROAD",
+  "NARROW",
+  "STEEP",
+  "LEVEL",
+  "SMOOTH",
+  "ROUGH",
+  "JAGGED",
+  "RUGGED",
+
+  // Compass & Navigation
+  "CHART",
+  "MAP",
+  "GUIDE",
+  "SIGNAL",
+  "MARKER",
+  "WAYPOINT",
+
+  // Time-related
+  "MOMENT",
+  "INSTANT",
+  "PULSE",
+  "CYCLE",
+  "PHASE",
+  "ERA",
+
+  // Types of Stone
+  "SLATE",
+  "SHALE",
+  "PUMICE",
+  "BASALT",
+  "GNEISS",
+  "SCHIST",
+
+  // Craft Tools
+  "AWL",
+  "FILE",
+  "RASP",
+  "PLANE",
+  "LATHE",
+  "TROWEL",
+  "MALLET",
+
+  // Garden
+  "HEDGE",
+  "LAWN",
+  "BOWER",
+  "TRELLIS",
+  "PERGOLA",
+
+  // Leather & Cloth
+  "HIDE",
+  "PELT",
+  "SUEDE",
+  "CHAMOIS",
+
+  // Additional verbs
+  "SEEK",
+  "FIND",
+  "FORGE",
+  "MEND",
+  "HONE",
+  "TEND",
+  "YIELD",
+  "SPARK",
+  "KINDLE",
+  "IGNITE",
+  "THRIVE",
+  "ENDURE",
+  "PREVAIL",
+
+  // Color modifiers
+  "ASHEN",
+  "DUSKY",
+  "MISTY",
+  "SMOKY",
+  "HAZY",
+  "FROSTY",
+  "SNOWY",
+  "EARTHY",
+  "MOSSY",
+  "SANDY",
+
+  // Additional gems/minerals
+  "ONYX",
+  "JADE",
+  "LAPIS",
+
+  // Water features
+  "POOL",
+  "WELL",
+  "WEIR",
+  "DAM",
+  "LOCK",
+  "SLUICE",
+  "SPOUT",
+  "TORRENT",
+  "RIPPLE",
+  "EDDY",
+
+  // Mountain terms
+  "SPUR",
+  "COL",
+  "SCREE",
+  "MORAINE",
+  "CIRQUE",
+
+  // Wind types
+  "ZEPHYR",
+  "CHINOOK",
+  "SIROCCO",
+  "MONSOON",
+
+  // Cloud types
+  "CIRRUS",
+  "STRATUS",
+  "NIMBUS",
+  "CUMULUS",
+
+  // Textile patterns
+  "TWEED",
+  "TARTAN",
+  "DAMASK",
+  "BROCADE",
+
+  // Ship types
+  "CUTTER",
+  "SLOOP",
+  "KETCH",
+  "CLIPPER",
+  "SCOW",
+
+  // Fortification
+  "MOAT",
+  "KEEP",
+  "BAILEY",
+  "BASTION",
+  "RAMPART",
+  "CITADEL",
+
+  // Forest types
+  "COPSE",
+  "BRAKE",
+  "SPINNEY",
+  "THICKET",
+
+  // Farm animals
+  "COLT",
+  "FOAL",
+  "MARE",
+  "STAG",
+  "ROOSTER",
+  "DRAKE",
+  "GANDER",
+
+  // Berry types
+  "SLOE",
+  "ELDER",
+
+  // Spices & herbs
+  "DILL",
+  "CHIVE",
+  "ANISE",
+  "CRESS",
+  "ENDIVE",
+
+  // More positive words
+  "ABLE",
+  "ADEPT",
+  "AMPLE",
+  "BENIGN",
+  "BRIGHT",
+  "BUOYANT",
+  "CANDID",
+  "COGENT",
+  "DEVOUT",
+  "EARNEST",
+  "FERVENT",
+  "GENIAL",
+  "HEARTY",
+  "INTACT",
+  "JAUNTY",
+  "KINDLY",
+  "LIVELY",
+  "MODEST",
+  "PLACID",
+  "QUAINT",
+  "ROBUST",
+  "SAVVY",
+  "STEADY",
+  "SUPPLE",
+  "TENDER",
+  "UPBEAT",
+  "VIBRANT",
+
+  // Additional astronomy
+  "CYGNUS",
+  "AQUILA",
+  "CORVUS",
+  "FORNAX",
+
+  // Woodworking
+  "GRAIN",
+  "BURR",
+  "KNURL",
+  "TENON",
+  "DADO",
+
+  // Pottery
+  "KILN",
+  "GLAZE",
+  "BISQUE",
+
+  // Weaving
+  "WARP",
+  "WEFT",
+  "HEDDLE",
+  "SHUTTLE",
+
+  // Musical dynamics
+  "PIANO",
+  "MEZZO",
+  "STACCATO",
+
+  // Additional dance
+  "BOLERO",
+  "MINUET",
+  "REEL",
+  "JIG",
+
+  // Fishing
+  "LURE",
+  "HOOK",
+  "REEL",
+  "TACKLE",
+
+  // Knots
+  "HITCH",
+  "CLOVE",
+  "BOWLINE",
+
+  // Garden flowers
+  "DAHLIA",
+  "AZALEA",
+  "ZINNIA",
+  "CROCUS",
+
+  // Fruits
+  "GUAVA",
+  "QUINCE",
+  "LYCHEE",
+  "PAPAYA",
+
+  // Nuts
+  "HAZEL",
+  "ACORN",
+  "CHESTNUT",
+
+  // Grains
+  "OATS",
+  "RYE",
+  "MILLET",
+  "BARLEY",
+  "QUINOA",
+
+  // Fabric weights
+  "GAUZE",
+  "MUSLIN",
+  "TULLE",
+  "CHIFFON",
+  "ORGANZA",
+
+  // Carpentry joints
+  "MITER",
+  "RABBET",
+  "MORTISE",
+
+  // Types of bridges
+  "SPAN",
+  "TRUSS",
+
+  // Additional landscape
+  "CAIRN",
+  "DOLMEN",
+  "BARROW",
+  "TUMULUS",
+
+  // Seafaring
+  "SHOAL",
+  "BERTH",
+  "HAVEN",
+  "MOORING",
+
+  // Woodlands
+  "BEECH",
+  "BIRCH",
+  "ALDER",
+  "HAWTHORN",
+
+  // Meadow features
+  "HILLOCK",
+  "HUMMOCK",
+  "FURROW",
+
+  // Weather events
+  "RAINBOW",
+  "SUNBOW",
+
+  // Archery
+  "ARCHER",
+  "QUIVER",
+  "TARGET",
+
+  // Sewing
+  "NEEDLE",
+  "BOBBIN",
+  "THIMBLE",
+
+  // More animals
+  "OSPREY",
+  "FALCON",
+  "MARTIN",
+  "SWIFT",
+  "DUNLIN",
+  "PIPIT",
+  "DIPPER",
+  "REDPOLL",
+  "SISKIN",
+  "LINNET",
+
+  // Landscape poetry
+  "GLEN",
+  "DALE",
+  "BECK",
+  "RILL",
+  "BURN",
+
+  // Timber
+  "BALSA",
+  "TEAK",
+  "EBONY",
+  "CHERRY",
+  "WALNUT",
+
+  // Final extras to ensure 2000+
+  "HARBOR",
+  "ANCHOR",
+  "VOYAGE",
+  "ODYSSEY",
+  "SAFARI",
+  "PICNIC",
+  "FIESTA",
+  "GALA",
+  "JUBILEE",
+  "PAGEANT",
+  "BAZAAR",
+  "MARKET",
+  "PLAZA",
+  "ARCADE",
+  "ATRIUM",
+  "STUDIO",
+  "GALLERY",
+  "MUSEUM",
+  "LIBRARY",
+  "CHAPEL",
+  "TEMPLE",
+  "PALACE",
+  "CASTLE",
+  "MANOR",
+  "LODGE",
+  "CABIN",
+  "CHALET",
+  "VILLA",
+  "COTTAGE",
+  "HOSTEL",
+  "TAVERN",
+  "BISTRO",
+  "PANTRY",
+  "CELLAR",
+  "ATTIC",
+  "PORCH",
+  "FOYER",
+  "VESTRY",
+  "ALCOVE",
+  "ROTUNDA",
+  "CUPOLA",
+  "MINARET",
+  "STEEPLE",
+  "HAMLET",
+  "VILLAGE",
+  "BOROUGH",
+  "CANTON",
+  "DUCHY",
+  "REALM",
+  "DOMAIN",
+  "ENCLAVE",
+  "OUTPOST",
+  "BASTION",
+  "REDOUBT",
+  "BULWARK",
+  "PARAPET",
+  "RAMPART",
+  "TURRET",
+  "GARLAND",
+  "WREATH",
+  "BOUQUET",
+  "NOSEGAY",
+  "CORSAGE",
+  "POSY",
+  "BALLAD",
+  "ANTHEM",
+  "DITTY",
+  "SHANTY",
+  "HYMN",
+  "PSALM",
+  "CAROL",
+  "CHANT",
+  "DIRGE",
+  "ELEGY",
+  "LAMENT",
+  "SONATA",
+  "FUGUE",
+  "RONDO",
+  "SUITE",
+  "ETUDE",
+  "OPUS",
+  "CADENCE",
+  "WALRUS",
+  "NARWHAL",
+  "BELUGA",
+  "PYTHON",
+  "IGUANA",
+  "MONITOR",
+  "MANTIS",
+  "CRICKET",
+  "FIREFLY",
+  "LADYBUG",
+  "MONARCH",
+  "SANDAL",
+  "CLOAK",
+  "TUNIC",
+  "TABARD",
+  "JERKIN",
+  "BONNET",
+  "BERET",
+  "FEDORA",
+  "TRILBY",
+  "AMULET",
+  "TALISMAN",
+  "TRINKET",
+  "LOCKET",
+  "PENDANT",
+  "BROOCH",
+  "CAMEO",
+  "TIARA",
+  "DIADEM",
+  "SCEPTER",
+  "CHALICE",
+  "GOBLET",
+  "FLAGON",
+  "MORTAR",
+  "PESTLE",
+  "CRUCIBLE",
+  "COMPASS",
+  "SEXTANT",
+  "OCTANT",
+  "SUNDIAL",
+  "GNOMON",
+  "BELLOWS",
+  "CRUCIBLE",
+  "ABACUS",
+  "STYLUS",
+  "QUILL",
+  "INKWELL",
+  "VELLUM",
+  "COBALT",
+  "PEWTER",
+  "BRONZE",
+  "NIMBLE",
+  "DAPPER",
+  "ASTUTE",
+  "SHREWD",
+  "POISED",
+  "POLISHED",
+  "REFINED",
+  "RUSTIC",
+  "QUAINT",
+  "CHARMING",
+  "SCENIC",
+  "BUCOLIC",
+  "PASTORAL",
+  "SERENE",
+  "TRANQUIL",
+  "HALCYON",
+
+  // Additional batch to reach 2000+
+  "ACORN",
+  "ADRIFT",
+  "ALPINE",
+  "AMBER",
+  "ANVIL",
+  "APRICOT",
+  "AURIC",
+  "AVID",
+  "AWNING",
+  "AZURE",
+  "BANNER",
+  "BARQUE",
+  "BASIL",
+  "BEACON",
+  "BEAKER",
+  "BELFRY",
+  "BILLOW",
+  "BLAZER",
+  "BONFIRE",
+  "BRAMBLE",
+  "BRAVO",
+  "BREAKER",
+  "BRIDLE",
+  "BRISTLE",
+  "BROMINE",
+  "BRONZE",
+  "BUCKLER",
+  "BUNKER",
+  "BURROW",
+  "CAPER",
+  "CAPSTAN",
+  "CARBINE",
+  "CARMINE",
+  "CASCADE",
+  "CASSIA",
+  "CATKIN",
+  "CAVERN",
+  "CELESTE",
+  "CENTAUR",
+  "CHINOOK",
+  "CHORTLE",
+  "CINDER",
+  "CITRINE",
+  "CITRUS",
+  "CLAMBER",
+  "CLARION",
+  "CLATTER",
+  "CLIMBER",
+  "CLUSTER",
+  "CODEX",
+  "COMMA",
+  "CONTOUR",
+  "CORSAIR",
+  "CRAFTER",
+  "CRANKLE",
+  "CREVICE",
+  "CURLEW",
+  "CURRENT",
+  "DAGGER",
+  "DAMSON",
+  "DAPPLE",
+  "DAZZLE",
+  "DECOY",
+  "DERRICK",
+  "DEWDROP",
+  "DINGHY",
+  "DIPPER",
+  "DIVOT",
+  "DORMER",
+  "DOSSAL",
+  "DRAFTER",
+  "DRAGNET",
+  "DRAWL",
+  "DRUMMER",
+  "DUCAT",
+  "DUNGEON",
+  "EDGING",
+  "EIDER",
+  "EMBARK",
+  "EMBER",
+  "EMBERS",
+  "ENSIGN",
+  "ERMINE",
+  "ESCORT",
+  "FABRIC",
+  "FALLOW",
+  "FARRIER",
+  "FEATHER",
+  "FENNEL",
+  "FERMENT",
+  "FESTIVAL",
+  "FIDDLER",
+  "FIGMENT",
+  "FILIGREE",
+  "FINERY",
+  "FLICKER",
+  "FLOTSAM",
+  "FLUTTER",
+  "FLYWHEEL",
+  "FONDANT",
+  "FOOTHILL",
+  "FOUNDER",
+  "FRESCO",
+  "FRONTIER",
+  "FURLONG",
+  "GABLE",
+  "GALLEON",
+  "GAMBIT",
+  "GARNET",
+  "GARRISON",
+  "GAZETTE",
+  "GELDING",
+  "GEYSER",
+  "GIMBAL",
+  "GINGER",
+  "GLAZIER",
+  "GOBLIN",
+  "GONDOLA",
+  "GRANITE",
+  "GRAPNEL",
+  "GRIZZLY",
+  "GRYPHON",
+  "GUILD",
+  "GUNWALE",
+  "HALYARD",
+  "HAMPER",
+  "HANGAR",
+  "HERALD",
+  "HARDTACK",
+  "HARNESS",
+  "HARVEST",
+  "HATCHET",
+  "HERMIT",
+  "HERRING",
+  "HOBBIT",
+  "HORNET",
+  "HOSIER",
+  "HURDLE",
+  "HUSSAR",
+  "ICECAP",
+  "IMPALA",
+  "INDRI",
+  "JACINTH",
+  "JAVELIN",
+  "JERSEY",
+  "JETTY",
+  "JUNCO",
+  "JUNIPER",
+  "KAYAKER",
+  "KERCHIEF",
+  "KETTLE",
+  "KINDRED",
+  "KINSFOLK",
+  "KNACKER",
+  "LACQUER",
+  "LANCET",
+  "LAPPING",
+  "LATTICE",
+  "LEMMING",
+  "LICHEN",
+  "LIGHTER",
+  "LISSOME",
+  "LOBSTER",
+  "LOOKOUT",
+  "LUSTRE",
+  "MACKEREL",
+  "MADDER",
+  "MAGNATE",
+  "MALLARD",
+  "MANDREL",
+  "MANSION",
+  "MANTLE",
+  "MARINER",
+  "MARLIN",
+  "MARMOT",
+  "MARTIAL",
+  "MASTIFF",
+  "MATADOR",
+  "MEDLAR",
+  "MERINO",
+  "MIDLAND",
+  "MILITIA",
+  "MILLDAM",
+  "MINNOW",
+  "MINOTAUR",
+  "MISTRAL",
+  "MONGREL",
+  "MONITOR",
+  "MOORHEN",
+  "MORAINE",
+  "MORELLO",
+  "MORNING",
+  "MULLET",
+  "MUSKET",
+  "MUSTARD",
+  "NAPHTHA",
+  "NARWHAL",
+  "NESTLE",
+  "NEUTRAL",
+  "NOMAD",
+  "NUTMEG",
+  "OARLOCK",
+  "OBELISK",
+  "OCTAVE",
+  "OLIVINE",
+  "OPALINE",
+  "OPTIMAL",
+  "ORCHID",
+  "PADDOCK",
+  "PALADIN",
+  "PALETTE",
+  "PANNIER",
+  "PAPYRUS",
+  "PARAGON",
+  "PARSNIP",
+  "PARTERRE",
+  "PATTERN",
+  "PAVILION",
+  "PEAFOWL",
+  "PEARLING",
+  "PELISSE",
+  "PENNON",
+  "PERGOLA",
+  "PERIWIG",
+  "PIASTER",
+  "PILGRIM",
+  "PINAFORE",
+  "PINNACLE",
+  "PIPETTE",
+  "PLATTER",
+  "PLINTH",
+  "PLOVER",
+  "PLUMAGE",
+  "PLUNDER",
+  "POLARIS",
+  "POMMEL",
+  "PONTOON",
+  "PORTICO",
+  "POTTAGE",
+  "PROWLER",
+  "PUMMEL",
+  "QUARREL",
+  "QUARRY",
+  "QUARTET",
+  "QUASAR",
+  "QUICKEN",
+  "QUILTER",
+  "RABBIT",
+  "RACQUET",
+  "RAFFIA",
+  "RAGTIME",
+  "RAMBLER",
+  "RAMROD",
+  "RAPIER",
+  "RAPTOR",
+  "RATCHET",
+  "REACTOR",
+  "REDWOOD",
+  "REGATTA",
+  "REMNANT",
+  "RETORT",
+  "RHYMER",
+  "RIGGER",
+  "RINGLET",
+  "RIVETER",
+  "ROAMER",
+  "ROOFTOP",
+  "ROSTER",
+  "RUDDER",
+  "RUNNER",
+  "RUPTURE",
+  "RUSSET",
+  "SAFFRON",
+  "SAMPLER",
+  "SANDBAR",
+  "SAPPER",
+  "SCARAB",
+  "SCOOTER",
+  "SCRIBER",
+  "SEAGULL",
+  "SEEKER",
+  "SELTZER",
+  "SENNET",
+  "SEQUIN",
+  "SERPENT",
+  "SETTLER",
+  "SHELTER",
+  "SHINGLE",
+  "SHOONER",
+  "SHUTTLE",
+  "SICKLE",
+  "SIDEKICK",
+  "SILVERN",
+  "SINKER",
+  "SKIPPER",
+  "SKYLARK",
+  "SMELTER",
+  "SNAPPER",
+  "SNIPER",
+  "SOLDIER",
+  "SOLVENT",
+  "SPANIEL",
+  "SPANNER",
+  "SPARTAN",
+  "SPIGOT",
+  "SPINDLE",
+  "GAFFER",
+  "SPLINTER",
+  "STALWART",
+  "STEAMER",
+  "STENCIL",
+  "STIRRUP",
+  "STOPPER",
+  "STRIDER",
+  "STRINGER",
+  "STRIKER",
+  "SUNBEAM",
+  "SUNDOWN",
+  "SUNRISE",
+  "SWALLOW",
+  "TABLETOP",
+  "TAFFETA",
+  "TAMBOUR",
+  "TANKARD",
+  "TAPROOT",
+  "TEMPLAR",
+  "THISTLE",
+  "THRASHER",
+  "TIFFANY",
+  "TILLER",
+  "TINKER",
+  "TOPKNOT",
+  "TOPSOIL",
+  "TORRENT",
+  "TRACKER",
+  "TRAMWAY",
+  "TREFOIL",
+  "TRIDENT",
+  "TRIGGER",
+  "TRIPOD",
+  "TROOPER",
+  "TRUMPET",
+  "TUGBOAT",
+  "TUMBLER",
+  "TWISTER",
+  "ULSTER",
+  "UMPIRE",
+  "UPRIGHT",
+  "UTENSIL",
+  "VAGRANT",
+  "VALIANT",
+  "VANGUARD",
+  "VAULTER",
+  "VERDANT",
+  "VERNIER",
+  "VICEROY",
+  "VINTNER",
+  "VOUCHER",
+  "VULTURE",
+  "WADDING",
+  "WALLEYE",
+  "WARBLER",
+  "WARRIOR",
+  "WAVELET",
+  "WEBBING",
+  "WHISKER",
+  "WHISTLE",
+  "WILDCAT",
+  "WINDMILL",
+  "BREAKER",
+  "WINGMAN",
+  "WISTERIA",
+  "WOODSMAN",
+  "WRANGLE",
+  "YEARLING",
+  "YEOMAN",
+  "ZEPHYR",
+  "ZIGZAG",
+  "ZITHER",
+
+  // Extra batch for 2000+
+  "ABBOT",
+  "ACER",
+  "ADMIRAL",
+  "AFFABLE",
+  "AGAVE",
+  "AILERON",
+  "ALBEDO",
+  "ALEWIFE",
+  "ALLSPICE",
+  "ALTITUDE",
+  "AMARANTH",
+  "AMETHYST",
+  "ANEMONE",
+  "ANGLER",
+  "ANTLER",
+  "APSE",
+  "ARBORIST",
+  "ARMADA",
+  "ARMLET",
+  "ARTISAN",
+  "ASHLAR",
+  "ATOLL",
+  "AUGER",
+  "BALDRIC",
+  "BALLAST",
+  "BANTAM",
+  "BARBERRY",
+  "BARDING",
+  "BARNACLE",
+  "BARRACK",
+  "BARRIER",
+  "BASINET",
+  "BASSOON",
+  "BATON",
+  "BASTION",
+  "BAYONET",
+  "BEDROCK",
+  "BELLBIRD",
+  "BERGAMOT",
+  "BITTERN",
+  "BLANKET",
+  "BLAZON",
+  "BLIZZARD",
+  "BLUEBILL",
+  "BOSUN",
+  "BODKIN",
+  "BOLLARD",
+  "BONFIRE",
+  "BORAX",
+  "BOSUN",
+  "BOWMAN",
+  "BRACKEN",
+  "BRACKET",
+  "BRAMBLE",
+  "BRAZIER",
+  "BRECCIA",
+  "BRIMMER",
+  "BROODER",
+  "BUNTING",
+  "BURGHER",
+  "BURNET",
+  "BURNISH",
+  "BURSAR",
+  "BUZZARD",
+  "CABERNET",
+  "CALIBER",
+  "CAMBRIC",
+  "CAMPHOR",
+  "CANOPY",
+  "CAPSTONE",
+  "CARAMEL",
+  "CARBINE",
+  "CARILLON",
+  "CASEMENT",
+  "CATAPULT",
+  "CAVALIER",
+  "CESSION",
+  "CHAFF",
+  "CHALICE",
+  "CHANCEL",
+  "CHAPTER",
+  "CHARTER",
+  "CHARCOAL",
+  "CHESSMAN",
+  "CHEVRON",
+  "CHIFFON",
+  "CHIMNEY",
+  "CHIPMUNK",
+  "COCOON",
+  "CISTERN",
+  "CITADEL",
+  "CLEMATIS",
+  "CLIPPER",
+  "CLUSTER",
+  "COASTER",
+  "COBBLER",
+  "COCKLE",
+  "COCONUT",
+  "CODFISH",
+  "COLLEGE",
+  "COLOMBO",
+  "COMFORT",
+  "COMMAND",
+  "CONIFER",
+  "CONSORT",
+  "CORBEL",
+  "CORACLE",
+  "CORANTO",
+  "CORNETT",
+  "CORSAIR",
+  "COUNCIL",
+  "COURIER",
+  "COWLING",
+  "CRANNOG",
+  "CRAPPIE",
+  "CRAWDAD",
+  "CREWMAN",
+  "CRUSADE",
+  "CRUISER",
+  "CURTAIN",
+  "CUTLASS",
+  "DAHLIA",
+  "DAMASK",
+  "DAPPLED",
+  "DARLING",
+  "DAUPHIN",
+  "DEADEYE",
+  "DECKHAND",
+  "DEEPFIN",
+  "DEWFALL",
+  "DISTAFF",
+  "DOUBLET",
+  "DOWAGER",
+  "DRAGOON",
+  "DRAYAGE",
+  "DROPLET",
+  "DRUMMER",
+  "DUCKLING",
+  "DUNGEON",
+  "DUSTPAN",
+  "EAGLET",
+  "ECHIDNA",
+  "EMBLEM",
+  "EMERALD",
+  "EMISSARY",
+  "EPAULET",
+  "EPERGNE",
+  "EYELASH",
+  "EYELET",
+  "FABRIC",
+  "FACETED",
+  "FALCONER",
+  "FARTHING",
+  "FELON",
+  "FIREBOLT",
+  "FIRKIN",
+  "FIXTURE",
+  "FLAGGING",
+  "FLEMISH",
+  "FLETCHER",
+  "FLICKER",
+  "FLORET",
+  "FLOTSAM",
+  "FLYOVER",
+  "FOGHORN",
+  "FOLIAGE",
+  "FOOTMAN",
+  "FORESTER",
+  "FOXGLOVE",
+  "FRIGATE",
+  "FROGMAN",
+  "FULLER",
+  "FURRIER",
+  "GADWALL",
+  "GALAHAD",
+  "GAMBREL",
+  "GANGWAY",
+  "GARGOYLE",
+  "GAZETTE",
+  "GELATIN",
+  "GENERAL",
+  "GENTIAN",
+  "GERANIUM",
+  "GHARIAL",
+  "GIMLET",
+  "FIGHTER",
+  "GONDOLA",
+  "GOSLING",
+  "GRANARY",
+  "GRANITE",
+  "LANCER",
+  "GRINDER",
+  "GROSBEAK",
+  "RAILING",
+  "GUFFAW",
+  "GUMPTION",
+  "GUNBOAT",
+  "DRAPER",
+  "HACKNEY",
+  "HALCYON",
+  "HALBERD",
+  "HALLMARK",
+  "HAMMOCK",
+  "HANDBELL",
+  "HATCHWAY",
+  "HAWKWEED",
+  "HAZELNUT",
+  "HEADLAND",
+  "HEDGEHOG",
+  "HELMSMAN",
+  "HERALDRY",
+  "BOTANIST",
+  "HIBISCUS",
+  "HILLTOP",
+  "HINGHAM",
+  "HOBNAIL",
+  "HYSSOP",
+  "INKBLOT",
+  "INLAY",
+  "JACKDAW",
+  "JAMBOREE",
+  "JAVELIN",
+  "JOURNAL",
+  "JOUSTING",
+  "JUNIPER",
+  "KEELBOAT",
+  "KERCHIEF",
+  "KINGBIRD",
+  "KINGFISH",
+  "KNAPSACK",
+  "LANYARD",
+  "LAPWING",
+  "LATERAL",
+  "LAVENDER",
+  "LAWSUIT",
+  "LEGHORN",
+  "LEOPARD",
+  "LIBERTY",
+  "LIGHTER",
+  "LINSEED",
+  "LOBELIA",
+  "LOCOWEED",
+  "LONGBOW",
+  "LOOKOUT",
+  "MACKEREL",
+  "MAGNOLIA",
+  "MAINSAIL",
+  "MAINSTAY",
+  "MANDOLIN",
+  "MANIFEST",
+  "MANTILLA",
+  "MAPPIST",
+  "MARIGOLD",
+  "MARITIME",
+  "MARKSMAN",
+  "MARSHMAN",
+  "MASTHEAD",
+  "MATLOCK",
+  "MAYAPPLE",
+  "HAWTHORN",
+
+  // Final unique batch
+  "ACRE",
+  "ADOPT",
+  "AFFORD",
+  "AGREE",
+  "ALBUM",
+  "ALIGN",
+  "ALLEY",
+  "ALLOW",
+  "ALOFT",
+  "AMAZE",
+  "ANNUAL",
+  "APART",
+  "APPEAL",
+  "ARENA",
+  "ARISE",
+  "ARMOR",
+  "ARRAY",
+  "ASCEND",
+  "ASSIST",
+  "ASTRAL",
+  "ATTIRE",
+  "AUGUST",
+  "AVAIL",
+  "AVENUE",
+  "AWAKEN",
+  "BADGE",
+  "BAKERY",
+  "BALLOT",
+  "BANDIT",
+  "BARREL",
+  "BASKET",
+  "BATTEN",
+  "BEAUTY",
+  "BELONG",
+  "BEYOND",
+  "BLANCH",
+  "BLENDE",
+  "BLOUSE",
+  "BOBCAT",
+  "BOREAL",
+  "BOTHER",
+  "BOUNCE",
+  "BOUNTY",
+  "BRANCH",
+  "BREACH",
+  "BROKER",
+  "BROWSE",
+  "BUCKET",
+  "BUDGET",
+  "BUFFER",
+  "BUTLER",
+  "BUTTON",
+  "BYPASS",
+  "CACTUS",
+  "CALLER",
+  "CAMPER",
+  "CANCEL",
+  "CANDLE",
+  "CAREER",
+  "CARPET",
+  "CARTON",
+  "CASUAL",
+  "CATNIP",
+  "CATTLE",
+  "CEMENT",
+  "CENSUS",
+  "CEREAL",
+  "CHOSEN",
+  "CLASSY",
+  "CLIENT",
+  "CLUTCH",
+  "COBWEB",
+  "COLLAR",
+  "COLONY",
+  "COMMON",
+  "CONVEY",
+  "COOKIE",
+  "COSMIC",
+  "COUNTY",
+  "CRAYON",
+  "CRUISE",
+  "DAMPEN",
+  "DEBATE",
+  "DECENT",
+  "DEFEAT",
+  "DEFEND",
+  "DEFINE",
+  "DEGREE",
+  "DEMAND",
+  "DEPLOY",
+  "DETAIL",
+  "DEVOTE",
+  "DIGEST",
+  "DIRECT",
+  "DIVIDE",
+  "DONATE",
+  "DOUBLE",
+  "DRIFTS",
+  "EDIBLE",
+  "EFFECT",
+  "EFFORT",
+  "EIGHTH",
+  "EMPIRE",
+  "ENABLE",
+  "ENERGY",
+  "ENGAGE",
+  "ENGINE",
+  "ENRICH",
+  "ENTIRE",
+  "ENVOY",
+  "ERRAND",
+  "FAMILY",
+  "FEALTY",
+  "FELINE",
+  "FIGURE",
+  "FINALE",
+  "FISCAL",
+  "FLANGE",
+  "FLAVOR",
+  "FLYING",
+  "FORAGE",
+  "FORMAT",
+  "FOSSIL",
+  "FROZEN",
+  "GADGET",
+  "GARLIC",
+  "GENIUS",
+  "GIGGLE",
+  "GIRDLE",
+  "GLOBAL",
+  "GOLDEN",
+  "GOPHER",
+  "GRASSY",
+  "GROCER",
+  "HAGGLE",
+  "HAZARD",
+  "HIDDEN",
+  "HOBBLE",
+  "HOMING",
+  "HUSTLE",
+  "IMMUNE",
+  "IMPACT",
+  "IMPORT",
+  "INDOOR",
+  "INFLUX",
+  "INSECT",
+  "INTENT",
+  "INTERN",
+  "INWARD",
+  "JACKET",
+  "JIGSAW",
+  "JOCKEY",
+  "JOSTLE",
+  "JUMBLE",
+  "KERNEL",
+  "KITTEN",
+] as const;
+
+export type InviteWord = (typeof WORD_LIST)[number];

+ 27 - 0
src/hooks/use-delete-movie.ts

@@ -0,0 +1,27 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+async function deleteMovie(movieId: string) {
+  const res = await fetch(`/api/movies/${movieId}`, {
+    method: "DELETE",
+  });
+
+  if (!res.ok) {
+    const data = await res.json().catch(() => ({}));
+    throw new Error(data.error || "Failed to delete movie");
+  }
+
+  return res.json();
+}
+
+export function useDeleteMovie(groupId: string) {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: deleteMovie,
+    onSuccess: () => {
+      void queryClient.invalidateQueries({ queryKey: ["movies", groupId] });
+    },
+  });
+}

+ 17 - 0
src/hooks/use-genre-filter.ts

@@ -0,0 +1,17 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+export function useGenreFilter() {
+  const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
+
+  const filterByGenre = useCallback((genre: string) => {
+    setSelectedGenre((prev) => (prev === genre ? null : genre));
+  }, []);
+
+  const clearGenreFilter = useCallback(() => {
+    setSelectedGenre(null);
+  }, []);
+
+  return { selectedGenre, filterByGenre, clearGenreFilter };
+}

+ 49 - 0
src/hooks/use-roll.ts

@@ -0,0 +1,49 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import type { Movie } from "@/types/movie";
+import { selectRandomMovie } from "@/lib/dice/randomizer";
+
+export type RollState = "idle" | "rolling" | "complete";
+
+const ANIMATION_DURATION_MS = 2500;
+
+export interface UseRollReturn {
+  result: Movie | null;
+  rollState: RollState;
+  roll: (eligibleMovies: Movie[]) => void;
+  reset: () => void;
+}
+
+export function useRoll(): UseRollReturn {
+  const [result, setResult] = useState<Movie | null>(null);
+  const [rollState, setRollState] = useState<RollState>("idle");
+  const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
+
+  useEffect(() => {
+    return () => {
+      if (timerRef.current) clearTimeout(timerRef.current);
+    };
+  }, []);
+
+  const roll = useCallback((eligibleMovies: Movie[]) => {
+    if (timerRef.current) clearTimeout(timerRef.current);
+
+    const winner = selectRandomMovie(eligibleMovies);
+
+    setRollState("rolling");
+    setResult(winner);
+
+    timerRef.current = setTimeout(() => {
+      setRollState("complete");
+    }, ANIMATION_DURATION_MS);
+  }, []);
+
+  const reset = useCallback(() => {
+    if (timerRef.current) clearTimeout(timerRef.current);
+    setResult(null);
+    setRollState("idle");
+  }, []);
+
+  return { result, rollState, roll, reset };
+}

+ 34 - 0
src/hooks/use-toggle-watched.ts

@@ -0,0 +1,34 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+interface ToggleWatchedVars {
+  movieId: string;
+  watched: boolean;
+}
+
+async function toggleWatched({ movieId, watched }: ToggleWatchedVars) {
+  const res = await fetch(`/api/movies/${movieId}/watched`, {
+    method: "PATCH",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ watched }),
+  });
+
+  if (!res.ok) {
+    const data = await res.json().catch(() => ({}));
+    throw new Error(data.error || "Failed to update watched status");
+  }
+
+  return res.json();
+}
+
+export function useToggleWatched(groupId: string) {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: toggleWatched,
+    onSuccess: () => {
+      void queryClient.invalidateQueries({ queryKey: ["movies", groupId] });
+    },
+  });
+}

+ 73 - 0
src/hooks/use-user-groups.ts

@@ -0,0 +1,73 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+
+export interface UserGroup {
+  id: string;
+  name: string;
+  created_by: string;
+  creator_name: string;
+  movie_count: number;
+}
+
+async function fetchUserGroups(): Promise<UserGroup[]> {
+  const supabase = getSupabaseBrowserClient();
+
+  const {
+    data: { user },
+  } = await supabase.auth.getUser();
+  if (!user) return [];
+
+  const { data: memberships, error: memberError } = await supabase
+    .from("group_members")
+    .select("group_id")
+    .eq("user_id", user.id);
+
+  if (memberError) throw memberError;
+  if (!memberships || memberships.length === 0) return [];
+
+  const groupIds = memberships.map((m) => m.group_id);
+
+  const [{ data: groups, error: groupError }, counts] = await Promise.all([
+    supabase.from("groups").select("id, name, created_by").in("id", groupIds),
+    Promise.all(
+      groupIds.map(async (groupId) => {
+        const { count } = await supabase
+          .from("movies")
+          .select("*", { count: "exact", head: true })
+          .eq("group_id", groupId);
+        return { groupId, count: count ?? 0 };
+      }),
+    ),
+  ]);
+
+  if (groupError) throw groupError;
+  if (!groups) return [];
+
+  const creatorIds = [...new Set(groups.map((g) => g.created_by))];
+  const { data: creators } = await supabase
+    .from("users")
+    .select("id, display_name")
+    .in("id", creatorIds);
+
+  const creatorMap = new Map(creators?.map((c) => [c.id, c.display_name]) ?? []);
+  const countMap = new Map(counts.map((c) => [c.groupId, c.count]));
+
+  return groups.map((group) => ({
+    id: group.id,
+    name: group.name,
+    created_by: group.created_by,
+    creator_name: creatorMap.get(group.created_by) ?? "Unknown",
+    movie_count: countMap.get(group.id) ?? 0,
+  }));
+}
+
+export function useUserGroups() {
+  return useQuery({
+    queryKey: ["user-groups"],
+    queryFn: fetchUserGroups,
+    staleTime: 30 * 1000,
+    refetchInterval: 30 * 1000,
+  });
+}

+ 9 - 0
src/instrumentation.ts

@@ -0,0 +1,9 @@
+export async function register() {
+  if (process.env.NEXT_RUNTIME === "nodejs") {
+    await import("../sentry.server.config");
+  }
+
+  if (process.env.NEXT_RUNTIME === "edge") {
+    await import("../sentry.edge.config");
+  }
+}

+ 22 - 0
src/lib/admin/session.ts

@@ -0,0 +1,22 @@
+import { getIronSession } from "iron-session";
+import { cookies } from "next/headers";
+import type { SessionOptions } from "iron-session";
+
+export interface AdminSessionData {
+  isAdmin: boolean;
+}
+
+export const adminSessionOptions: SessionOptions = {
+  password: process.env.IRON_SESSION_SECRET!,
+  cookieName: "moviedice-admin",
+  cookieOptions: {
+    secure: true,
+    sameSite: "strict",
+    httpOnly: true,
+    maxAge: 28800, // 8 hours
+  },
+};
+
+export async function getAdminSession() {
+  return getIronSession<AdminSessionData>(await cookies(), adminSessionOptions);
+}

+ 7 - 0
src/lib/admin/totp.ts

@@ -0,0 +1,7 @@
+import { authenticator } from "otplib";
+
+export function verifyTotp(token: string): boolean {
+  const secret = process.env.MASTER_ADMIN_TOTP_SECRET;
+  if (!secret) return false;
+  return authenticator.check(token, secret);
+}

+ 2 - 0
src/lib/constants.ts

@@ -58,3 +58,5 @@ export const RECOVERY_CODE_LENGTH = 24;
 export const MOVIES_PER_PAGE = 12;
 export const SEARCH_DEBOUNCE_MS = 300;
 export const REEL_POSTER_COUNT = 20;
+
+export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

+ 59 - 0
src/lib/dice/genre-filter.ts

@@ -0,0 +1,59 @@
+import type { Movie } from "@/types/movie";
+import { EMOTION_TO_GENRE_MAP, NOSTALGIC_KEYWORDS, NOSTALGIC_YEAR_CUTOFF } from "@/lib/constants";
+
+export interface FilterResult {
+  movies: Movie[];
+  noMatches: boolean;
+}
+
+export function filterByGenresAndEmotions(input: string, movies: Movie[]): FilterResult {
+  const tokens = tokenize(input);
+  if (tokens.length === 0) {
+    return { movies, noMatches: false };
+  }
+
+  const genreIds = new Set<number>();
+  let hasNostalgic = false;
+
+  for (const token of tokens) {
+    if (NOSTALGIC_KEYWORDS.has(token)) {
+      hasNostalgic = true;
+      continue;
+    }
+
+    const mapping = EMOTION_TO_GENRE_MAP[token];
+    if (mapping) {
+      for (const id of mapping.primary) genreIds.add(id);
+      for (const id of mapping.secondary) genreIds.add(id);
+    }
+  }
+
+  let filtered = movies;
+
+  if (hasNostalgic) {
+    filtered = filtered.filter((m) => m.year < NOSTALGIC_YEAR_CUTOFF);
+  }
+
+  if (genreIds.size > 0) {
+    filtered = filtered.filter((m) =>
+      m.genres.some((genre) => {
+        const genreId = parseInt(genre, 10);
+        return !isNaN(genreId) && genreIds.has(genreId);
+      }),
+    );
+  }
+
+  if (filtered.length === 0) {
+    return { movies, noMatches: true };
+  }
+
+  return { movies: filtered, noMatches: false };
+}
+
+function tokenize(input: string): string[] {
+  return input
+    .toLowerCase()
+    .split(/[,\s]+/)
+    .map((t) => t.trim())
+    .filter((t) => t.length > 0);
+}

+ 16 - 0
src/lib/dice/randomizer.ts

@@ -0,0 +1,16 @@
+import type { Movie } from "@/types/movie";
+
+export function selectRandomMovie(movies: Movie[]): Movie | null {
+  const unwatched = movies.filter((m) => !m.watched);
+  if (unwatched.length === 0) return null;
+  if (unwatched.length === 1) return unwatched[0];
+
+  const randomIndex = cryptoRandomIndex(unwatched.length);
+  return unwatched[randomIndex];
+}
+
+function cryptoRandomIndex(max: number): number {
+  const array = new Uint32Array(1);
+  crypto.getRandomValues(array);
+  return array[0] % max;
+}

+ 14 - 0
src/lib/groups/delete-group.ts

@@ -0,0 +1,14 @@
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+
+/** Delete a group and all associated data (members, movies). */
+export async function deleteGroupAndData(groupId: string): Promise<void> {
+  const admin = getSupabaseAdminClient();
+
+  await Promise.all([
+    admin.from("group_members").delete().eq("group_id", groupId),
+    admin.from("movies").delete().eq("group_id", groupId),
+  ]);
+  const { error } = await admin.from("groups").delete().eq("id", groupId);
+
+  if (error) throw error;
+}

+ 33 - 0
src/lib/groups/invite-code.ts

@@ -0,0 +1,33 @@
+import { WORD_LIST } from "@/data/word-list";
+import { INVITE_CODE_SEPARATOR } from "@/lib/constants";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+
+/**
+ * Generate a unique WORD-WORD invite code.
+ * Picks 2 random words, joins with separator, checks DB for collision.
+ * Retries up to 10 times on collision.
+ */
+export async function generateInviteCode(): Promise<string> {
+  const admin = getSupabaseAdminClient();
+  const maxAttempts = 10;
+
+  for (let i = 0; i < maxAttempts; i++) {
+    const word1 = WORD_LIST[Math.floor(Math.random() * WORD_LIST.length)];
+    const word2 = WORD_LIST[Math.floor(Math.random() * WORD_LIST.length)];
+    const code = `${word1}${INVITE_CODE_SEPARATOR}${word2}`;
+
+    const { data } = await admin.from("groups").select("id").eq("invite_code", code).maybeSingle();
+
+    if (!data) return code;
+  }
+
+  throw new Error("Failed to generate unique invite code after maximum attempts");
+}
+
+/**
+ * Normalize an invite code for case-insensitive comparison.
+ * Stored as uppercase, compared as uppercase.
+ */
+export function normalizeInviteCode(code: string): string {
+  return code.trim().toUpperCase();
+}

+ 68 - 0
src/lib/groups/rate-limit.ts

@@ -0,0 +1,68 @@
+/**
+ * In-memory rate limiter for group join endpoint.
+ * Tracks attempts per IP within a sliding window.
+ */
+
+interface RateLimitEntry {
+  timestamps: number[];
+}
+
+const store = new Map<string, RateLimitEntry>();
+
+const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
+const MAX_ATTEMPTS = 10;
+
+/** Remove expired timestamps from an entry. */
+function cleanup(entry: RateLimitEntry, now: number): void {
+  entry.timestamps = entry.timestamps.filter((t) => now - t < WINDOW_MS);
+}
+
+/**
+ * Check if a request from the given IP is allowed.
+ * Returns { allowed, remaining, resetAt }.
+ */
+export function checkRateLimit(ip: string): {
+  allowed: boolean;
+  remaining: number;
+  resetAt: number;
+} {
+  const now = Date.now();
+  let entry = store.get(ip);
+
+  if (!entry) {
+    entry = { timestamps: [] };
+    store.set(ip, entry);
+  }
+
+  cleanup(entry, now);
+
+  const allowed = entry.timestamps.length < MAX_ATTEMPTS;
+  const remaining = Math.max(0, MAX_ATTEMPTS - entry.timestamps.length);
+  const resetAt = entry.timestamps.length > 0 ? entry.timestamps[0] + WINDOW_MS : now + WINDOW_MS;
+
+  if (allowed) {
+    entry.timestamps.push(now);
+  }
+
+  return { allowed, remaining, resetAt };
+}
+
+// Periodic cleanup of stale entries (every 5 minutes).
+// unref() prevents the timer from keeping the process alive.
+if (typeof setInterval !== "undefined") {
+  const timer = setInterval(
+    () => {
+      const now = Date.now();
+      for (const [ip, entry] of store) {
+        cleanup(entry, now);
+        if (entry.timestamps.length === 0) {
+          store.delete(ip);
+        }
+      }
+    },
+    5 * 60 * 1000,
+  );
+  if (typeof timer === "object" && "unref" in timer) {
+    timer.unref();
+  }
+}

+ 8 - 0
src/lib/groups/validation.ts

@@ -0,0 +1,8 @@
+import { z } from "zod";
+import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
+
+export const groupNameSchema = z
+  .string()
+  .min(1, "Group name is required")
+  .max(GROUP_NAME_MAX_LENGTH, `Group name must be ${GROUP_NAME_MAX_LENGTH} characters or less`)
+  .trim();

+ 25 - 0
src/lib/sentry-utils.ts

@@ -0,0 +1,25 @@
+import type { Event } from "@sentry/nextjs";
+
+const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
+
+/** Strip UUID v4 segments from Sentry events to prevent PII leakage. */
+export function stripUuids<T extends Event>(event: T): T {
+  if (event.request?.url) {
+    event.request.url = event.request.url.replace(UUID_RE, "[id]");
+  }
+  if (event.breadcrumbs) {
+    for (const crumb of event.breadcrumbs) {
+      if (typeof crumb.message === "string") {
+        crumb.message = crumb.message.replace(UUID_RE, "[id]");
+      }
+      if (crumb.data) {
+        for (const key of Object.keys(crumb.data)) {
+          if (typeof crumb.data[key] === "string") {
+            crumb.data[key] = (crumb.data[key] as string).replace(UUID_RE, "[id]");
+          }
+        }
+      }
+    }
+  }
+  return event;
+}

+ 24 - 0
src/lib/tmdb-fetch.ts

@@ -0,0 +1,24 @@
+import { env } from "@/env";
+import { TMDB_API_BASE_URL } from "@/lib/constants";
+import type { TMDBSearchResponse, TMDBMovie } from "@/types/tmdb";
+
+export async function fetchTMDBMovies(
+  path: string,
+  params: URLSearchParams,
+): Promise<{ movies: TMDBMovie[]; error?: string; status?: number }> {
+  const res = await fetch(`${TMDB_API_BASE_URL}${path}?${params.toString()}`, {
+    headers: {
+      Authorization: `Bearer ${env.TMDB_API_KEY}`,
+      Accept: "application/json",
+    },
+    next: { revalidate: 3600 },
+  });
+
+  if (!res.ok) {
+    return { movies: [], error: "TMDB request failed", status: res.status };
+  }
+
+  const data: TMDBSearchResponse = await res.json();
+  const movies = data.results.filter((m) => !m.adult && m.poster_path);
+  return { movies };
+}

+ 3 - 0
src/types/movie.ts

@@ -0,0 +1,3 @@
+import type { Database } from "@/types/database";
+
+export type Movie = Database["public"]["Tables"]["movies"]["Row"];