Przeglądaj źródła

Merge branch 'worktree-agent-a87de7fc'

User 2 miesięcy temu
rodzic
commit
53e61b36b3

+ 148 - 0
src/app/api/movies/route.ts

@@ -0,0 +1,148 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { z } from "zod";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { env } from "@/env";
+import { TMDB_API_BASE_URL, TRAILER_DOMAIN_ALLOWLIST } from "@/lib/constants";
+import type { Database } from "@/types/database";
+import type { TMDBMovieDetails, TMDBVideosResponse } from "@/types/tmdb";
+
+type MovieInsert = Database["public"]["Tables"]["movies"]["Insert"];
+
+const addMovieSchema = z.object({
+  tmdb_id: z.number().int().positive(),
+  group_id: z.string().uuid(),
+});
+
+function buildTrailerUrl(videos: TMDBVideosResponse): string | null {
+  // Prefer official YouTube trailers, then any YouTube video
+  const sorted = [...videos.results]
+    .filter((v) => v.site === "YouTube" && v.key)
+    .sort((a, b) => {
+      if (a.official !== b.official) return a.official ? -1 : 1;
+      if (a.type !== b.type) {
+        if (a.type === "Trailer") return -1;
+        if (b.type === "Trailer") return 1;
+      }
+      return 0;
+    });
+
+  const video = sorted[0];
+  if (!video) return null;
+
+  return `https://www.youtube.com/watch?v=${video.key}`;
+}
+
+function isAllowedTrailerDomain(url: string): boolean {
+  try {
+    const { hostname } = new URL(url);
+    return TRAILER_DOMAIN_ALLOWLIST.some(
+      (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
+    );
+  } catch {
+    return false;
+  }
+}
+
+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 = addMovieSchema.safeParse(body);
+    if (!parsed.success) {
+      return NextResponse.json(
+        { error: "Invalid input", details: parsed.error.flatten().fieldErrors },
+        { status: 400 },
+      );
+    }
+
+    const { tmdb_id, group_id } = parsed.data;
+
+    // Verify user is a member of this group
+    const { data: membership } = await supabase
+      .from("group_members")
+      .select("user_id")
+      .eq("group_id", group_id)
+      .eq("user_id", user.id)
+      .single();
+
+    if (!membership) {
+      return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
+    }
+
+    // Check if movie already exists in this group
+    const { data: existing } = await supabase
+      .from("movies")
+      .select("id")
+      .eq("group_id", group_id)
+      .eq("tmdb_id", tmdb_id)
+      .single();
+
+    if (existing) {
+      return NextResponse.json({ error: "Movie already in group" }, { status: 409 });
+    }
+
+    // Fetch movie details from TMDB
+    const [detailsRes, videosRes] = await Promise.all([
+      fetch(`${TMDB_API_BASE_URL}/movie/${tmdb_id}?api_key=${env.TMDB_API_KEY}`),
+      fetch(`${TMDB_API_BASE_URL}/movie/${tmdb_id}/videos?api_key=${env.TMDB_API_KEY}`),
+    ]);
+
+    if (!detailsRes.ok) {
+      return NextResponse.json({ error: "Movie not found on TMDB" }, { status: 404 });
+    }
+
+    const details: TMDBMovieDetails = await detailsRes.json();
+
+    if (details.adult) {
+      return NextResponse.json({ error: "Adult content not allowed" }, { status: 400 });
+    }
+
+    let trailerUrl: string | null = null;
+    if (videosRes.ok) {
+      const videos: TMDBVideosResponse = await videosRes.json();
+      const url = buildTrailerUrl(videos);
+      if (url && isAllowedTrailerDomain(url)) {
+        trailerUrl = url;
+      }
+    }
+
+    const year = details.release_date ? parseInt(details.release_date.substring(0, 4), 10) : 0;
+    const genres = details.genres.map((g) => g.name);
+
+    const insertData: MovieInsert = {
+      group_id,
+      tmdb_id,
+      title: details.title,
+      year,
+      poster_path: details.poster_path,
+      genres,
+      trailer_url: trailerUrl,
+      trailer_url_refreshed_at: trailerUrl ? new Date().toISOString() : null,
+      added_by: user.id,
+    };
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const { data: movie, error } = await (supabase.from("movies") as any)
+      .insert(insertData)
+      .select()
+      .single();
+
+    if (error) {
+      console.error("Failed to insert movie:", error);
+      return NextResponse.json({ error: "Failed to add movie" }, { status: 500 });
+    }
+
+    return NextResponse.json({ movie }, { status: 201 });
+  } catch (err) {
+    console.error("POST /api/movies error:", err);
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 88 - 0
src/components/movies/search-bar.tsx

@@ -0,0 +1,88 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import { SEARCH_DEBOUNCE_MS } from "@/lib/constants";
+
+interface SearchBarProps {
+  onSearch: (query: string) => void;
+  isLoading?: boolean;
+}
+
+export function SearchBar({ onSearch, isLoading }: SearchBarProps) {
+  const [value, setValue] = useState("");
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const debouncedSearch = useCallback(
+    (term: string) => {
+      if (timerRef.current) clearTimeout(timerRef.current);
+      timerRef.current = setTimeout(() => {
+        onSearch(term.trim());
+      }, SEARCH_DEBOUNCE_MS);
+    },
+    [onSearch],
+  );
+
+  useEffect(() => {
+    return () => {
+      if (timerRef.current) clearTimeout(timerRef.current);
+    };
+  }, []);
+
+  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
+    const next = e.target.value;
+    setValue(next);
+    debouncedSearch(next);
+  }
+
+  function handleClear() {
+    setValue("");
+    onSearch("");
+    if (timerRef.current) clearTimeout(timerRef.current);
+  }
+
+  return (
+    <div className="relative">
+      <input
+        type="search"
+        value={value}
+        onChange={handleChange}
+        placeholder="Search for a movie..."
+        aria-label="Search movies"
+        className="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 pr-20 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
+      />
+      <div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3">
+        {isLoading && (
+          <svg
+            className="h-4 w-4 animate-spin text-gray-400"
+            viewBox="0 0 24 24"
+            fill="none"
+            aria-hidden="true"
+          >
+            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+            />
+          </svg>
+        )}
+        {value && (
+          <button
+            type="button"
+            onClick={handleClear}
+            className="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+            aria-label="Clear search"
+          >
+            <svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+              <path
+                fillRule="evenodd"
+                d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
+                clipRule="evenodd"
+              />
+            </svg>
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}

+ 67 - 0
src/components/movies/search-result-card.tsx

@@ -0,0 +1,67 @@
+"use client";
+
+import { getTMDBImageUrl } from "@/types/tmdb";
+
+interface SearchResultCardProps {
+  title: string;
+  year: number | null;
+  posterPath: string | null;
+  inList: boolean;
+  isAdding?: boolean;
+  onAdd?: () => void;
+}
+
+export function SearchResultCard({
+  title,
+  year,
+  posterPath,
+  inList,
+  isAdding,
+  onAdd,
+}: SearchResultCardProps) {
+  const imageUrl = getTMDBImageUrl(posterPath, "grid");
+  const yearLabel = year && year > 0 ? ` (${year})` : "";
+
+  return (
+    <button
+      type="button"
+      onClick={inList ? undefined : onAdd}
+      disabled={inList || isAdding}
+      className={`flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors ${
+        inList
+          ? "cursor-default bg-indigo-50 dark:bg-indigo-900/20"
+          : "hover:bg-gray-100 dark:hover:bg-gray-700"
+      } disabled:opacity-60`}
+      aria-label={inList ? `${title}${yearLabel} — already in your list` : `Add ${title}${yearLabel}`}
+    >
+      {imageUrl ? (
+        /* eslint-disable-next-line @next/next/no-img-element -- TMDB posters use native URLs per project convention */
+        <img
+          src={imageUrl}
+          alt={`Poster for ${title}`}
+          loading="lazy"
+          className="h-16 w-11 shrink-0 rounded object-cover"
+        />
+      ) : (
+        <div
+          className="flex h-16 w-11 shrink-0 items-center justify-center rounded bg-gray-200 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400"
+          aria-hidden="true"
+        >
+          N/A
+        </div>
+      )}
+      <div className="min-w-0 flex-1">
+        <p className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
+          {title}
+          {yearLabel && <span className="ml-1 font-normal text-gray-500 dark:text-gray-400">{yearLabel}</span>}
+        </p>
+        {inList && <p className="text-xs text-indigo-600 dark:text-indigo-400">In your list</p>}
+      </div>
+      {!inList && (
+        <span className="shrink-0 text-xs font-medium text-indigo-600 dark:text-indigo-400" aria-hidden="true">
+          {isAdding ? "Adding..." : "+ Add"}
+        </span>
+      )}
+    </button>
+  );
+}

+ 98 - 0
src/components/movies/search-results.tsx

@@ -0,0 +1,98 @@
+"use client";
+
+import { SearchResultCard } from "./search-result-card";
+import type { TMDBMovie } from "@/types/tmdb";
+import type { Database } from "@/types/database";
+
+type GroupMovie = Database["public"]["Tables"]["movies"]["Row"];
+
+interface SearchResultsProps {
+  tmdbResults: TMDBMovie[];
+  groupMovies: GroupMovie[];
+  query: string;
+  isAdding: boolean;
+  addingTmdbId: number | null;
+  onAdd: (tmdbId: number) => void;
+}
+
+export function SearchResults({
+  tmdbResults,
+  groupMovies,
+  query,
+  isAdding,
+  addingTmdbId,
+  onAdd,
+}: SearchResultsProps) {
+  if (!query || query.length < 2) return null;
+
+  const groupTmdbIds = new Set(groupMovies.map((m) => m.tmdb_id));
+
+  // Filter group movies that match the search query
+  const lowerQuery = query.toLowerCase();
+  const matchingGroupMovies = groupMovies.filter((m) =>
+    m.title.toLowerCase().includes(lowerQuery),
+  );
+
+  // Filter TMDB results: exclude movies already in the group
+  const newResults = tmdbResults.filter((m) => !groupTmdbIds.has(m.id));
+
+  const hasInList = matchingGroupMovies.length > 0;
+  const hasNew = newResults.length > 0;
+
+  if (!hasInList && !hasNew) {
+    return <p className="py-4 text-center text-sm text-gray-500">No results found.</p>;
+  }
+
+  return (
+    <div className="space-y-4" aria-live="polite">
+      {hasInList && (
+        <section>
+          <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
+            In Your List
+          </h3>
+          <div className="space-y-1">
+            {matchingGroupMovies.map((movie) => (
+              <SearchResultCard
+                key={`group-${movie.id}`}
+                title={movie.title}
+                year={movie.year}
+                posterPath={movie.poster_path}
+                inList
+              />
+            ))}
+          </div>
+        </section>
+      )}
+
+      {hasInList && hasNew && (
+        <hr className="border-gray-200 dark:border-gray-700" />
+      )}
+
+      {hasNew && (
+        <section>
+          <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
+            TMDB Results
+          </h3>
+          <div className="space-y-1">
+            {newResults.map((movie) => {
+              const year = movie.release_date
+                ? parseInt(movie.release_date.substring(0, 4), 10)
+                : null;
+              return (
+                <SearchResultCard
+                  key={`tmdb-${movie.id}`}
+                  title={movie.title}
+                  year={year}
+                  posterPath={movie.poster_path}
+                  inList={false}
+                  isAdding={isAdding && addingTmdbId === movie.id}
+                  onAdd={() => onAdd(movie.id)}
+                />
+              );
+            })}
+          </div>
+        </section>
+      )}
+    </div>
+  );
+}

+ 34 - 0
src/hooks/use-add-movie.ts

@@ -0,0 +1,34 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { Database } from "@/types/database";
+
+type Movie = Database["public"]["Tables"]["movies"]["Row"];
+
+interface AddMovieInput {
+  tmdb_id: number;
+  group_id: string;
+}
+
+export function useAddMovie() {
+  const queryClient = useQueryClient();
+
+  return useMutation<Movie, Error, AddMovieInput>({
+    mutationFn: async (input) => {
+      const res = await fetch("/api/movies", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(input),
+      });
+
+      if (!res.ok) {
+        const body = await res.json().catch(() => null);
+        throw new Error(body?.error ?? "Failed to add movie");
+      }
+
+      const data = await res.json();
+      return data.movie;
+    },
+    onSuccess: (_data, variables) => {
+      void queryClient.invalidateQueries({ queryKey: ["group-movies", variables.group_id] });
+    },
+  });
+}

+ 15 - 0
src/hooks/use-movie-search.ts

@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import type { TMDBSearchResponse } from "@/types/tmdb";
+
+export function useMovieSearch(query: string) {
+  return useQuery<TMDBSearchResponse>({
+    queryKey: ["tmdb-search", query],
+    queryFn: async () => {
+      const res = await fetch(`/api/tmdb/search?query=${encodeURIComponent(query)}`);
+      if (!res.ok) throw new Error("Search failed");
+      return res.json();
+    },
+    enabled: query.length >= 2,
+    staleTime: 60 * 1000,
+  });
+}