瀏覽代碼

Merge branch 'worktree-agent-a15e2803'

User 2 月之前
父節點
當前提交
9ff9805cac
共有 4 個文件被更改,包括 212 次插入0 次删除
  1. 33 0
      src/app/(app)/list/[id]/loading.tsx
  2. 88 0
      src/app/(app)/list/[id]/page.tsx
  3. 25 0
      src/app/api/health/route.ts
  4. 66 0
      src/middleware.ts

+ 33 - 0
src/app/(app)/list/[id]/loading.tsx

@@ -0,0 +1,33 @@
+export default function ListLoading() {
+  return (
+    <div className="flex min-h-screen flex-col">
+      {/* Header skeleton */}
+      <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
+        <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
+          <div className="h-7 w-40 animate-pulse rounded bg-white/10" />
+          <div className="h-9 w-9 animate-pulse rounded-lg bg-white/10" />
+        </div>
+      </header>
+
+      <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
+        {/* Search bar skeleton */}
+        <div className="mb-6">
+          <div className="h-10 animate-pulse rounded-lg bg-white/10" />
+        </div>
+
+        {/* Roll buttons skeleton */}
+        <div className="mb-8 flex gap-3">
+          <div className="h-12 flex-1 animate-pulse rounded-lg bg-white/10" />
+          <div className="h-12 flex-1 animate-pulse rounded-lg bg-white/10" />
+        </div>
+
+        {/* Movie grid skeleton */}
+        <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
+          {Array.from({ length: 8 }).map((_, i) => (
+            <div key={i} className="aspect-[2/3] animate-pulse rounded-lg bg-white/10" />
+          ))}
+        </div>
+      </main>
+    </div>
+  );
+}

+ 88 - 0
src/app/(app)/list/[id]/page.tsx

@@ -0,0 +1,88 @@
+import { notFound } from "next/navigation";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+
+interface ListPageProps {
+  params: Promise<{ id: string }>;
+}
+
+export default async function ListPage({ params }: ListPageProps) {
+  const { id } = await params;
+  const supabase = await getSupabaseServerClient();
+
+  const { data } = await supabase.from("groups").select("id, name").eq("id", id).single();
+
+  if (!data) {
+    notFound();
+  }
+
+  const group = data as { id: string; name: string };
+
+  return (
+    <div className="flex min-h-screen flex-col">
+      {/* Header */}
+      <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
+        <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
+          <h1 className="truncate text-xl font-bold">{group.name}</h1>
+          <a
+            href={`/list/${group.id}/settings`}
+            className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+            aria-label="Group settings"
+          >
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="20"
+              height="20"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              aria-hidden="true"
+            >
+              <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
+              <circle cx="12" cy="12" r="3" />
+            </svg>
+          </a>
+        </div>
+      </header>
+
+      <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
+        {/* Search bar area */}
+        <div className="mb-6">
+          <div className="h-10 rounded-lg border border-white/10 bg-white/5" />
+        </div>
+
+        {/* Roll buttons area */}
+        <div className="mb-8 flex gap-3">
+          <div className="h-12 flex-1 rounded-lg bg-white/5" />
+          <div className="h-12 flex-1 rounded-lg bg-white/5" />
+        </div>
+
+        {/* Movie grid area */}
+        <section aria-label="Movie list">
+          <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
+            {/* Movie cards will be rendered here by client components */}
+          </div>
+        </section>
+
+        {/* Watched section */}
+        <section className="mt-8 border-t border-white/10 pt-6" aria-label="Watched movies">
+          <details>
+            <summary className="cursor-pointer text-sm font-medium text-foreground/60 hover:text-foreground">
+              Watched
+            </summary>
+            <div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
+              {/* Watched movie cards will be rendered here */}
+            </div>
+          </details>
+        </section>
+      </main>
+
+      {/* TMDB footer */}
+      <footer className="border-t border-white/10 py-4 text-center text-xs text-foreground/40">
+        <p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
+      </footer>
+    </div>
+  );
+}

+ 25 - 0
src/app/api/health/route.ts

@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+  try {
+    const supabase = getSupabaseAdminClient();
+    const { error } = await supabase.from("users").select("id").limit(1);
+
+    if (error) {
+      return NextResponse.json(
+        { status: "error", message: "Database connectivity check failed" },
+        { status: 503 },
+      );
+    }
+
+    return NextResponse.json({ status: "ok", timestamp: Date.now() });
+  } catch {
+    return NextResponse.json(
+      { status: "error", message: "Health check failed" },
+      { status: 503 },
+    );
+  }
+}

+ 66 - 0
src/middleware.ts

@@ -0,0 +1,66 @@
+import { createServerClient, type CookieOptions } from "@supabase/ssr";
+import { NextResponse, type NextRequest } from "next/server";
+
+export async function middleware(request: NextRequest) {
+  let response = NextResponse.next({ request });
+
+  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+  const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+
+  if (!supabaseUrl || !supabaseAnonKey) {
+    return response;
+  }
+
+  const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
+    cookies: {
+      getAll() {
+        return request.cookies.getAll();
+      },
+      setAll(
+        cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>,
+      ) {
+        cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
+        response = NextResponse.next({ request });
+        cookiesToSet.forEach(({ name, value, options }) =>
+          response.cookies.set(name, value, options),
+        );
+      },
+    },
+  });
+
+  const {
+    data: { user },
+  } = await supabase.auth.getUser();
+
+  const { pathname } = request.nextUrl;
+
+  // Authenticated user on landing page -> redirect to home
+  if (user && pathname === "/") {
+    const url = request.nextUrl.clone();
+    url.pathname = "/home";
+    return NextResponse.redirect(url);
+  }
+
+  // Unauthenticated user accessing app routes -> redirect to landing
+  if (!user && (pathname.startsWith("/list") || pathname.startsWith("/home"))) {
+    const url = request.nextUrl.clone();
+    url.pathname = "/";
+    return NextResponse.redirect(url);
+  }
+
+  return response;
+}
+
+export const config = {
+  matcher: [
+    /*
+     * Match all request paths except:
+     * - _next/static (static files)
+     * - _next/image (image optimization)
+     * - favicon.ico (favicon)
+     * - /api/* (API routes)
+     * - /admin/* (admin routes - has its own auth)
+     */
+    "/((?!_next/static|_next/image|favicon\\.ico|api/|admin).*)",
+  ],
+};