From cabd4803a4f5bfd8b548af1c0608c13e0dc7d79c Mon Sep 17 00:00:00 2001 From: Juan Marquez Date: Wed, 10 Jun 2026 18:30:46 +0200 Subject: [PATCH] first commit --- .dockerignore | 6 + Dockerfile | 37 + app/api/cards/[type]/route.ts | 30 + app/api/health/route.ts | 14 + app/api/search/route.ts | 21 + app/globals.css | 17 +- app/layout.tsx | 10 +- app/movie/[id]/page.tsx | 41 + app/page.tsx | 82 +- components/InfoCard.tsx | 35 + components/MovieHeader.tsx | 76 + components/MovieInsightPanel.tsx | 49 + components/SearchBar.tsx | 161 ++ components/cards/CardContent.tsx | 115 ++ components/cards/HistoricalAccuracy.tsx | 76 + components/cards/InternationalRatings.tsx | 57 + components/cards/ProductionFacts.tsx | 70 + components/cards/RatingExplained.tsx | 74 + components/cards/SensitivityGuide.tsx | 56 + components/cards/SkipGuide.tsx | 124 ++ docker-compose.dev.yml | 24 + docker-compose.yml | 35 + docker-entrypoint.sh | 9 + drizzle.config.ts | 10 + drizzle/migrations/0000_init.sql | 9 + lib/cache.ts | 47 + lib/card-generator.ts | 116 ++ lib/cards.ts | 59 + lib/db/index.ts | 36 + lib/db/schema.ts | 28 + lib/llm.ts | 69 + lib/schemas/index.ts | 103 ++ lib/tmdb.ts | 157 ++ lib/utils.ts | 6 + next.config.ts | 13 +- package-lock.json | 1783 ++++++++++++++++++++- package.json | 15 +- scripts/migrate.mjs | 22 + scripts/migrate.ts | 24 + 39 files changed, 3635 insertions(+), 81 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/api/cards/[type]/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/movie/[id]/page.tsx create mode 100644 components/InfoCard.tsx create mode 100644 components/MovieHeader.tsx create mode 100644 components/MovieInsightPanel.tsx create mode 100644 components/SearchBar.tsx create mode 100644 components/cards/CardContent.tsx create mode 100644 components/cards/HistoricalAccuracy.tsx create mode 100644 components/cards/InternationalRatings.tsx create mode 100644 components/cards/ProductionFacts.tsx create mode 100644 components/cards/RatingExplained.tsx create mode 100644 components/cards/SensitivityGuide.tsx create mode 100644 components/cards/SkipGuide.tsx create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 drizzle.config.ts create mode 100644 drizzle/migrations/0000_init.sql create mode 100644 lib/cache.ts create mode 100644 lib/card-generator.ts create mode 100644 lib/cards.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/llm.ts create mode 100644 lib/schemas/index.ts create mode 100644 lib/tmdb.ts create mode 100644 lib/utils.ts create mode 100644 scripts/migrate.mjs create mode 100644 scripts/migrate.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..56ad452 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +.env.local +*.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7fd14e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/node_modules ./node_modules +COPY docker-entrypoint.sh ./docker-entrypoint.sh + +RUN chmod +x docker-entrypoint.sh + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +ENTRYPOINT ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/app/api/cards/[type]/route.ts b/app/api/cards/[type]/route.ts new file mode 100644 index 0000000..eb55bfb --- /dev/null +++ b/app/api/cards/[type]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isCardType } from "@/lib/cards"; +import { generateCard } from "@/lib/card-generator"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ type: string }> }, +) { + const { type } = await params; + const movieId = Number(request.nextUrl.searchParams.get("movieId")); + + if (!isCardType(type)) { + return NextResponse.json({ error: "Unknown card type" }, { status: 400 }); + } + + if (!Number.isFinite(movieId) || movieId <= 0) { + return NextResponse.json({ error: "Invalid movie ID" }, { status: 400 }); + } + + try { + const result = await generateCard(movieId, type); + return NextResponse.json(result); + } catch (error) { + console.error(`Card generation failed (${type}):`, error); + return NextResponse.json( + { error: "Something went wrong. Try again." }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..46800f4 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { checkDbConnection } from "@/lib/db"; + +export async function GET() { + const dbHealthy = await checkDbConnection(); + + return NextResponse.json( + { + status: dbHealthy ? "ok" : "degraded", + db: dbHealthy ? "connected" : "unavailable", + }, + { status: dbHealthy ? 200 : 503 }, + ); +} \ No newline at end of file diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..973c3c2 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchMovies } from "@/lib/tmdb"; + +export async function GET(request: NextRequest) { + const query = request.nextUrl.searchParams.get("q") ?? ""; + + if (!query.trim()) { + return NextResponse.json({ results: [] }); + } + + try { + const results = await searchMovies(query); + return NextResponse.json({ results }); + } catch (error) { + console.error("Search failed:", error); + return NextResponse.json( + { error: "Something went wrong. Try again." }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index a2dc41e..94d202c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,8 +1,8 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #020617; + --foreground: #f8fafc; } @theme inline { @@ -12,15 +12,12 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), system-ui, sans-serif; } + +* { + box-sizing: border-box; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..7e2fadf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Film Intel", + description: "Advanced movie insight you won't find in one click", }; export default function RootLayout({ @@ -27,7 +27,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); -} +} \ No newline at end of file diff --git a/app/movie/[id]/page.tsx b/app/movie/[id]/page.tsx new file mode 100644 index 0000000..4586ae0 --- /dev/null +++ b/app/movie/[id]/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { MovieHeader } from "@/components/MovieHeader"; +import { MovieInsightPanel } from "@/components/MovieInsightPanel"; +import { getMovieDetails } from "@/lib/tmdb"; + +export default async function MoviePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const movieId = Number(id); + + if (!Number.isFinite(movieId) || movieId <= 0) { + notFound(); + } + + let movie; + try { + movie = await getMovieDetails(movieId); + } catch { + notFound(); + } + + return ( +
+
+ + genre.name)} + /> +
+ +
+ AI-generated content for informational purposes. Verify timestamps on + your copy. +
+
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..4f93bed 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,29 @@ -import Image from "next/image"; +import { SearchBar } from "@/components/SearchBar"; -export default function Home() { +export default function HomePage() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. +
+
+
+

+ Film Intel +

+

+ Movie insight you won't find in one click

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+ Ratings explained, skip guides, sensitivity warnings, and deep + production context for any film.

- -
-

+ + +
+ + + ); -} +} \ No newline at end of file diff --git a/components/InfoCard.tsx b/components/InfoCard.tsx new file mode 100644 index 0000000..88006d0 --- /dev/null +++ b/components/InfoCard.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { CardMeta } from "@/lib/cards"; + +interface InfoCardProps { + meta: CardMeta; + selected: boolean; + onClick: () => void; +} + +export function InfoCard({ meta, selected, onClick }: InfoCardProps) { + return ( + + ); +} \ No newline at end of file diff --git a/components/MovieHeader.tsx b/components/MovieHeader.tsx new file mode 100644 index 0000000..9cce01d --- /dev/null +++ b/components/MovieHeader.tsx @@ -0,0 +1,76 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { getPosterUrl, getUsCertification, type TmdbMovieDetails } from "@/lib/tmdb"; + +export function MovieHeader({ movie }: { movie: TmdbMovieDetails }) { + const poster = getPosterUrl(movie.poster_path, "w500"); + const year = movie.release_date?.slice(0, 4); + const certification = getUsCertification(movie); + + return ( +
+ + + Back to search + + +
+
+ {poster ? ( + {movie.title} + ) : ( +
+ No poster +
+ )} +
+ +
+
+

+ {movie.title} +

+

+ {[year, movie.runtime ? `${movie.runtime} min` : null] + .filter(Boolean) + .join(" · ")} +

+
+ +
+ {certification ? ( + + {certification} + + ) : null} + {movie.genres.map((genre) => ( + + {genre.name} + + ))} +
+ + {movie.overview ? ( +

+ {movie.overview} +

+ ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/MovieInsightPanel.tsx b/components/MovieInsightPanel.tsx new file mode 100644 index 0000000..f41fdc5 --- /dev/null +++ b/components/MovieInsightPanel.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { + CARD_META, + CARD_TYPES, + shouldShowHistoricalAccuracy, + type CardType, +} from "@/lib/cards"; +import { CardContent } from "./cards/CardContent"; +import { InfoCard } from "./InfoCard"; + +export function MovieInsightPanel({ + movieId, + genres, +}: { + movieId: number; + genres: string[]; +}) { + const visibleCards = CARD_TYPES.filter((type) => { + if (type === "historical_accuracy") { + return shouldShowHistoricalAccuracy(genres); + } + return true; + }); + + const [selected, setSelected] = useState(visibleCards[0]); + + return ( +
+
+ {visibleCards.map((type) => ( + setSelected(type)} + /> + ))} +
+ + +
+ ); +} \ No newline at end of file diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx new file mode 100644 index 0000000..d0c474c --- /dev/null +++ b/components/SearchBar.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Search } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { getPosterUrl } from "@/lib/tmdb"; +import { cn } from "@/lib/utils"; + +interface SearchResult { + id: number; + title: string; + release_date: string; + poster_path: string | null; + overview: string; +} + +export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) { + const containerRef = useRef(null); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!query.trim()) { + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(async () => { + setLoading(true); + try { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query)}`, + { signal: controller.signal }, + ); + const data = (await response.json()) as { results: SearchResult[] }; + setResults(data.results ?? []); + setOpen(true); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setResults([]); + } + } finally { + setLoading(false); + } + }, 300); + + return () => { + clearTimeout(timeout); + controller.abort(); + }; + }, [query]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + + if (open) { + document.addEventListener("click", handleClickOutside); + } + + return () => document.removeEventListener("click", handleClickOutside); + }, [open]); + + return ( +
+
+ + { + const value = event.target.value; + setQuery(value); + if (!value.trim()) { + setResults([]); + setOpen(false); + } + }} + onFocus={() => query.trim() && setOpen(true)} + placeholder="Search for a movie..." + className="w-full rounded-2xl border border-white/10 bg-white/5 py-4 pl-12 pr-4 text-base text-white outline-none transition focus:border-amber-400/40 focus:bg-white/[0.07] focus:ring-2 focus:ring-amber-400/20" + /> +
+ + {open && ( +
    + {loading && ( +
  • Searching...
  • + )} + {!loading && results.length === 0 && ( +
  • No results found
  • + )} + {!loading && + results.map((result) => { + const poster = getPosterUrl(result.poster_path, "w185"); + const year = result.release_date?.slice(0, 4); + + return ( +
  • + setOpen(false)} + className="flex items-center gap-3 border-b border-white/5 px-4 py-3 transition hover:bg-white/5 last:border-b-0" + > +
    + {poster ? ( + + ) : ( +
    + N/A +
    + )} +
    +
    +

    + {result.title} + {year ? ( + + ({year}) + + ) : null} +

    +

    + {result.overview || "No overview available"} +

    +
    + +
  • + ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/cards/CardContent.tsx b/components/cards/CardContent.tsx new file mode 100644 index 0000000..ba216fe --- /dev/null +++ b/components/cards/CardContent.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { CardType } from "@/lib/cards"; +import { HistoricalAccuracyView } from "./HistoricalAccuracy"; +import { InternationalRatingsView } from "./InternationalRatings"; +import { ProductionFactsView } from "./ProductionFacts"; +import { RatingExplainedView } from "./RatingExplained"; +import { SensitivityGuideView } from "./SensitivityGuide"; +import { SkipGuideView } from "./SkipGuide"; + +interface CardContentProps { + movieId: number; + cardType: CardType; +} + +export function CardContent({ movieId, cardType }: CardContentProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryToken, setRetryToken] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + async function fetchCard() { + try { + const response = await fetch( + `/api/cards/${cardType}?movieId=${movieId}`, + { signal: controller.signal }, + ); + const payload = await response.json(); + + if (!response.ok) { + throw new Error(payload.error ?? "Something went wrong. Try again."); + } + + if (!cancelled) { + setData(payload.data); + setError(null); + } + } catch (err) { + if ((err as Error).name === "AbortError" || cancelled) { + return; + } + setError( + err instanceof Error ? err.message : "Something went wrong. Try again.", + ); + setData(null); + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void fetchCard(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [cardType, movieId, retryToken]); + + if (loading) { + return ( +
+
+
+
+

Generating insight...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!data) { + return null; + } + + switch (cardType) { + case "rating_explained": + return ; + case "skip_guide": + return ; + case "sensitivity_guide": + return ; + case "international_ratings": + return ; + case "production_facts": + return ; + case "historical_accuracy": + return ; + default: + return null; + } +} \ No newline at end of file diff --git a/components/cards/HistoricalAccuracy.tsx b/components/cards/HistoricalAccuracy.tsx new file mode 100644 index 0000000..f2c82e3 --- /dev/null +++ b/components/cards/HistoricalAccuracy.tsx @@ -0,0 +1,76 @@ +import { + historicalAccuracySchema, + type HistoricalAccuracy, +} from "@/lib/schemas"; + +export function HistoricalAccuracyView({ data }: { data: unknown }) { + const result = historicalAccuracySchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as HistoricalAccuracy; + + return ( +
+
+
+

+ Accuracy score +

+

+ {parsed.accuracyScore} + /100 +

+
+

+ Confidence: {parsed.confidence} +

+
+ +

{parsed.summary}

+ + {parsed.accurateElements.length > 0 && ( +
+

What holds up

+
    + {parsed.accurateElements.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ )} + + {parsed.inventions.length > 0 && ( +
+

+ Dramatizations & inventions +

+
+ {parsed.inventions.map((item, index) => ( +
+

+ Film claim: {item.claim} +

+

+ Reality: {item.reality} +

+
+ ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/cards/InternationalRatings.tsx b/components/cards/InternationalRatings.tsx new file mode 100644 index 0000000..a433840 --- /dev/null +++ b/components/cards/InternationalRatings.tsx @@ -0,0 +1,57 @@ +import { + internationalRatingsSchema, + type InternationalRatings, +} from "@/lib/schemas"; + +export function InternationalRatingsView({ data }: { data: unknown }) { + const result = internationalRatingsSchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as InternationalRatings; + + return ( +
+
+ {parsed.ratings.map((rating) => ( +
+

+ {rating.country} ({rating.countryCode}) +

+

+ {rating.rating} +

+ {rating.descriptors?.length ? ( +

+ {rating.descriptors.join(" · ")} +

+ ) : null} +
+ ))} +
+ +
+

+ {parsed.discrepancyNote} +

+ {(parsed.strictestCountry || parsed.mostLenientCountry) && ( +
+ {parsed.strictestCountry ? ( + Strictest: {parsed.strictestCountry} + ) : null} + {parsed.mostLenientCountry ? ( + Most lenient: {parsed.mostLenientCountry} + ) : null} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/cards/ProductionFacts.tsx b/components/cards/ProductionFacts.tsx new file mode 100644 index 0000000..b09a324 --- /dev/null +++ b/components/cards/ProductionFacts.tsx @@ -0,0 +1,70 @@ +import { + productionFactsSchema, + type ProductionFacts, +} from "@/lib/schemas"; + +export function ProductionFactsView({ data }: { data: unknown }) { + const result = productionFactsSchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as ProductionFacts; + + return ( +
+ {(parsed.budget || parsed.boxOffice) && ( +
+ {parsed.budget ? : null} + {parsed.boxOffice ? ( + + ) : null} +
+ )} + + {parsed.filmingLocations?.length ? ( +
+

Filming locations

+
+ {parsed.filmingLocations.map((location) => ( + + {location} + + ))} +
+
+ ) : null} + +
+ {parsed.facts.map((item, index) => ( +
+

+ {item.category} +

+

+ {item.fact} +

+
+ ))} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} \ No newline at end of file diff --git a/components/cards/RatingExplained.tsx b/components/cards/RatingExplained.tsx new file mode 100644 index 0000000..3434dda --- /dev/null +++ b/components/cards/RatingExplained.tsx @@ -0,0 +1,74 @@ +import { + ratingExplainedSchema, + type RatingExplained, +} from "@/lib/schemas"; +import { cn } from "@/lib/utils"; + +const intensityColors = { + mild: "text-emerald-300 bg-emerald-400/10 border-emerald-400/20", + moderate: "text-amber-300 bg-amber-400/10 border-amber-400/20", + strong: "text-red-300 bg-red-400/10 border-red-400/20", +}; + +export function RatingExplainedView({ data }: { data: unknown }) { + const result = ratingExplainedSchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as RatingExplained; + + return ( +
+
+

+ {parsed.region} rating +

+

+ {parsed.officialRating} +

+

{parsed.summary}

+
+ +
+ {parsed.factors.map((factor, index) => ( +
+
+ + {factor.category} + + + {factor.intensity} + + {factor.approximateTiming ? ( + + ~{factor.approximateTiming} + + ) : null} +
+

+ {factor.description} +

+
+ ))} +
+ + {parsed.borderlineNote ? ( +

+ {parsed.borderlineNote} +

+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/components/cards/SensitivityGuide.tsx b/components/cards/SensitivityGuide.tsx new file mode 100644 index 0000000..e3a9118 --- /dev/null +++ b/components/cards/SensitivityGuide.tsx @@ -0,0 +1,56 @@ +import { + sensitivityGuideSchema, + type SensitivityGuide, +} from "@/lib/schemas"; +import { cn } from "@/lib/utils"; + +const severityColors = { + mild: "border-emerald-400/20 bg-emerald-400/5", + moderate: "border-amber-400/20 bg-amber-400/5", + strong: "border-red-400/20 bg-red-400/5", +}; + +export function SensitivityGuideView({ data }: { data: unknown }) { + const result = sensitivityGuideSchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as SensitivityGuide; + + return ( +
+

{parsed.summary}

+ +
+ {parsed.themes.map((theme, index) => ( +
+
+

{theme.theme}

+ + {theme.severity} + + {theme.approximateTiming ? ( + + ~{theme.approximateTiming} + + ) : null} +
+

+ {theme.description} +

+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/cards/SkipGuide.tsx b/components/cards/SkipGuide.tsx new file mode 100644 index 0000000..d5b4c0d --- /dev/null +++ b/components/cards/SkipGuide.tsx @@ -0,0 +1,124 @@ +import { skipGuideSchema, type SkipGuide } from "@/lib/schemas"; +import { cn } from "@/lib/utils"; + +const recommendationStyles = { + recommended: "bg-red-400/15 text-red-200 border-red-400/25", + optional: "bg-amber-400/15 text-amber-200 border-amber-400/25", + do_not_skip: "bg-slate-400/15 text-slate-200 border-slate-400/25", +}; + +const recommendationLabels = { + recommended: "Recommended skip", + optional: "Optional skip", + do_not_skip: "Do not skip", +}; + +function parseTimestamp(value: string): number { + const hours = value.match(/(\d+)\s*h/i); + const minutes = value.match(/(\d+)\s*m/i); + return (hours ? Number(hours[1]) * 60 : 0) + (minutes ? Number(minutes[1]) : 0); +} + +export function SkipGuideView({ data }: { data: unknown }) { + const result = skipGuideSchema.safeParse(data); + if (!result.success) { + return ( +
+ Could not display this insight (data format issue). Try another card or refresh later. +
+ ); + } + const parsed = result.data as SkipGuide; + const maxMinutes = Math.max( + ...parsed.segments.map((segment) => parseTimestamp(segment.end)), + 120, + ); + + return ( +
+
+ + + +
+ +
+

Content timeline

+
+ {parsed.segments.map((segment, index) => { + const start = parseTimestamp(segment.start); + const end = parseTimestamp(segment.end); + const left = (start / maxMinutes) * 100; + const width = Math.max(((end - start) / maxMinutes) * 100, 1.5); + + return ( +
+ ); + })} +
+
+ 0:00 + {Math.floor(maxMinutes / 60)}h {maxMinutes % 60}m +
+
+ +
+ {parsed.segments.map((segment, index) => ( +
+
+ + {segment.start} – {segment.end} + + + {recommendationLabels[segment.skipRecommendation]} + + + Plot impact: {segment.plotImpact.replace("_", " ")} + +
+

{segment.reason}

+

+ Resume hint:{" "} + {segment.resumeHint} +

+
+ ))} +
+ +

{parsed.disclaimer}

+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..05f6f6a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,24 @@ +services: + app: + build: + context: . + target: deps + command: npm run dev + working_dir: /app + volumes: + - .:/app + - /app/node_modules + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://film:film@db:5432/film + TMDB_API_KEY: ${TMDB_API_KEY} + LLM_API_KEY: ${LLM_API_KEY} + LLM_BASE_URL: ${LLM_BASE_URL:-https://openrouter.ai/api/v1} + LLM_MODEL: ${LLM_MODEL:-openai/gpt-4o-mini} + LLM_HTTP_REFERER: ${LLM_HTTP_REFERER:-http://localhost:3000} + LLM_APP_TITLE: ${LLM_APP_TITLE:-Film Intel} + WATCHPACK_POLLING: "true" + depends_on: + db: + condition: service_healthy \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..161b654 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + app: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://film:film@db:5432/film + TMDB_API_KEY: ${TMDB_API_KEY} + LLM_API_KEY: ${LLM_API_KEY} + LLM_BASE_URL: ${LLM_BASE_URL:-https://openrouter.ai/api/v1} + LLM_MODEL: ${LLM_MODEL:-openai/gpt-4o-mini} + LLM_HTTP_REFERER: ${LLM_HTTP_REFERER:-http://localhost:3000} + LLM_APP_TITLE: ${LLM_APP_TITLE:-Film Intel} + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: film + POSTGRES_PASSWORD: film + POSTGRES_DB: film + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U film -d film"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..661c00c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +if [ -n "$DATABASE_URL" ]; then + echo "Running database migrations..." + node scripts/migrate.mjs +fi + +exec node server.js \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..909efb1 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./lib/db/schema.ts", + out: "./drizzle/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); \ No newline at end of file diff --git a/drizzle/migrations/0000_init.sql b/drizzle/migrations/0000_init.sql new file mode 100644 index 0000000..00d13dd --- /dev/null +++ b/drizzle/migrations/0000_init.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "card_cache" ( + "id" serial PRIMARY KEY NOT NULL, + "movie_id" integer NOT NULL, + "card_type" varchar(50) NOT NULL, + "payload" jsonb NOT NULL, + "created_at" timestamptz DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "card_cache_movie_card_unique" ON "card_cache" ("movie_id", "card_type"); \ No newline at end of file diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..c6451ea --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,47 @@ +import { and, eq } from "drizzle-orm"; +import type { CardType } from "./cards"; +import { getDb } from "./db"; +import { cardCache } from "./db/schema"; + +export async function getCachedCard( + movieId: number, + cardType: CardType, +): Promise { + const db = getDb(); + const rows = await db + .select() + .from(cardCache) + .where( + and(eq(cardCache.movieId, movieId), eq(cardCache.cardType, cardType)), + ) + .limit(1); + + if (rows.length === 0) { + return null; + } + + return rows[0].payload as T; +} + +export async function setCachedCard( + movieId: number, + cardType: CardType, + payload: T, +): Promise { + const db = getDb(); + + await db + .insert(cardCache) + .values({ + movieId, + cardType, + payload, + }) + .onConflictDoUpdate({ + target: [cardCache.movieId, cardCache.cardType], + set: { + payload, + createdAt: new Date(), + }, + }); +} \ No newline at end of file diff --git a/lib/card-generator.ts b/lib/card-generator.ts new file mode 100644 index 0000000..8356edb --- /dev/null +++ b/lib/card-generator.ts @@ -0,0 +1,116 @@ +import type { CardType } from "./cards"; +import { getCachedCard, setCachedCard } from "./cache"; +import { generateStructuredResponse } from "./llm"; +import { + historicalAccuracySchema, + internationalRatingsSchema, + productionFactsSchema, + ratingExplainedSchema, + sensitivityGuideSchema, + skipGuideSchema, +} from "./schemas"; +import { formatMovieContext, getMovieDetails } from "./tmdb"; + +const PROMPTS: Record = { + rating_explained: `Analyze why this film received its age rating. +You MUST return ONLY valid JSON matching this exact structure (no extra keys, no markdown): +{ + "officialRating": string, + "region": string, + "summary": string, + "factors": array of { + "category": one of exactly ["violence", "language", "sexuality", "substances", "frightening", "other"] (use ONLY these strings, no synonyms like "gore" or "nudity" — map to closest), + "intensity": one of exactly ["mild", "moderate", "strong"], + "description": string, + "approximateTiming": string (optional, e.g. "12:45"), + "confidence": one of ["high", "medium", "low"] (optional) + }, + "borderlineNote": string (optional) +} +Focus on specific scenes or content patterns that drove the rating. Be precise with the allowed category and intensity values.`, + + skip_guide: `Create a skip guide for families who want to watch this film with a younger audience. +You MUST return ONLY valid JSON matching this exact structure: +{ + "originalRating": string, + "suggestedAudienceAge": number (integer 0-21), + "totalSkippedMinutes": number (integer), + "segments": array of { + "start": string (e.g. "0:05" or "12m"), + "end": string, + "reason": string, + "intensity": one of exactly ["mild", "moderate", "strong"], + "plotImpact": one of exactly ["none", "minor", "significant"], + "skipRecommendation": one of exactly ["recommended", "optional", "do_not_skip"], + "resumeHint": string + }, + "disclaimer": string +} +Only include genuinely skippable segments. Mark plot-critical scenes as skipRecommendation "do_not_skip". +Timestamps should be approximate for the standard theatrical cut. Use only the exact enum strings listed.`, + + sensitivity_guide: `Create a sensitivity guide grouping potentially upsetting content by theme. +You MUST return ONLY valid JSON: +{ + "summary": string, + "themes": array of { + "theme": string, + "severity": one of exactly ["mild", "moderate", "strong"], + "description": string, + "approximateTiming": string (optional) + } +} +Cover violence, sexuality, substances, self-harm, abuse, animal harm, discrimination, and other difficult themes where relevant. Use only the exact "severity" values.`, + + international_ratings: `Summarize how this film is rated across countries using the certification data provided. +Return JSON with: ratings (array of {country, countryCode, rating, descriptors?}), discrepancyNote, strictestCountry (optional), mostLenientCountry (optional). +Include at least US, GB, ES, DE, FR when data exists. Explain notable differences.`, + + production_facts: `Curate interesting behind-the-scenes production facts for cinephiles. +Return JSON with: facts (array of at least 5 {category, fact}), budget (optional string), boxOffice (optional string), filmingLocations (optional array of strings). +Prefer specific, verifiable-sounding details; note uncertainty in the fact text when needed.`, + + historical_accuracy: `Evaluate historical accuracy for this biographical or historical film. +You MUST return ONLY valid JSON: +{ + "accuracyScore": number (0-100), + "summary": string, + "accurateElements": string[], + "inventions": array of { "claim": string, "reality": string }, + "confidence": one of exactly ["high", "medium", "low"] +} +If the film is not historical/biographical, still assess any real-world basis it claims. Use only the exact confidence value.`, +}; + +const SCHEMAS = { + rating_explained: ratingExplainedSchema, + skip_guide: skipGuideSchema, + sensitivity_guide: sensitivityGuideSchema, + international_ratings: internationalRatingsSchema, + production_facts: productionFactsSchema, + historical_accuracy: historicalAccuracySchema, +} as const; + +export async function generateCard(movieId: number, cardType: CardType) { + const cached = await getCachedCard(movieId, cardType); + if (cached) { + const schema = SCHEMAS[cardType]; + if (schema.safeParse(cached).success) { + return { data: cached, cached: true }; + } + // stale/invalid cached data (e.g. from before prompt/schema tightening) — regenerate + } + + const movie = await getMovieDetails(movieId); + const context = formatMovieContext(movie); + const prompt = `${PROMPTS[cardType]} + +Movie context: +${context}`; + + const schema = SCHEMAS[cardType]; + const data = await generateStructuredResponse(schema, prompt); + await setCachedCard(movieId, cardType, data); + + return { data, cached: false }; +} \ No newline at end of file diff --git a/lib/cards.ts b/lib/cards.ts new file mode 100644 index 0000000..e165a10 --- /dev/null +++ b/lib/cards.ts @@ -0,0 +1,59 @@ +export const CARD_TYPES = [ + "rating_explained", + "skip_guide", + "sensitivity_guide", + "international_ratings", + "production_facts", + "historical_accuracy", +] as const; + +export type CardType = (typeof CARD_TYPES)[number]; + +export interface CardMeta { + title: string; + subtitle: string; + badge?: string; +} + +export const CARD_META: Record = { + rating_explained: { + title: "Rating Explained", + subtitle: "Why this film has its age rating", + }, + skip_guide: { + title: "Skip Guide", + subtitle: "Scenes you can skip for a younger audience", + badge: "Families", + }, + sensitivity_guide: { + title: "Sensitivity Guide", + subtitle: "Content warnings by theme", + }, + international_ratings: { + title: "International Ratings", + subtitle: "How ratings differ by country", + }, + production_facts: { + title: "Production Facts", + subtitle: "Behind-the-scenes facts", + }, + historical_accuracy: { + title: "Historical Accuracy", + subtitle: "Fact vs fiction breakdown", + }, +}; + +const HISTORICAL_GENRES = new Set([ + "history", + "war", + "biography", + "documentary", +]); + +export function shouldShowHistoricalAccuracy(genres: string[]): boolean { + return genres.some((genre) => HISTORICAL_GENRES.has(genre.toLowerCase())); +} + +export function isCardType(value: string): value is CardType { + return CARD_TYPES.includes(value as CardType); +} \ No newline at end of file diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..2959c96 --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,36 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; + +const connectionString = process.env.DATABASE_URL; + +let client: ReturnType | null = null; +let db: ReturnType> | null = null; + +export function getDb() { + if (!connectionString) { + throw new Error("DATABASE_URL is not set"); + } + + if (!client) { + client = postgres(connectionString, { max: 10 }); + db = drizzle(client, { schema }); + } + + return db!; +} + +export async function checkDbConnection(): Promise { + if (!connectionString) { + return false; + } + + try { + const probe = postgres(connectionString, { max: 1 }); + await probe`SELECT 1`; + await probe.end(); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..18eff52 --- /dev/null +++ b/lib/db/schema.ts @@ -0,0 +1,28 @@ +import { + integer, + jsonb, + pgTable, + serial, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; + +export const cardCache = pgTable( + "card_cache", + { + id: serial("id").primaryKey(), + movieId: integer("movie_id").notNull(), + cardType: varchar("card_type", { length: 50 }).notNull(), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("card_cache_movie_card_unique").on( + table.movieId, + table.cardType, + ), + ], +); \ No newline at end of file diff --git a/lib/llm.ts b/lib/llm.ts new file mode 100644 index 0000000..e637b65 --- /dev/null +++ b/lib/llm.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +const SYSTEM_PROMPT = `You are a film research assistant for an international audience. +You MUST respond in English with valid JSON only — no markdown fences, no commentary. +Use a clear, neutral tone. If uncertain about a scene or timestamp, state uncertainty and set confidence to "low". +Do not invent scenes, quotes, or timestamps. Base answers on the movie context provided.`; + +export async function generateStructuredResponse( + schema: T, + userPrompt: string, +): Promise> { + const apiKey = process.env.LLM_API_KEY; + if (!apiKey) { + throw new Error("LLM_API_KEY is not set"); + } + + const baseUrl = process.env.LLM_BASE_URL ?? "https://api.openai.com/v1"; + const model = process.env.LLM_MODEL ?? "gpt-4o-mini"; + + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + + if (process.env.LLM_HTTP_REFERER) { + headers["HTTP-Referer"] = process.env.LLM_HTTP_REFERER; + } + + if (process.env.LLM_APP_TITLE) { + headers["X-Title"] = process.env.LLM_APP_TITLE; + } + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + temperature: 0.2, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`LLM request failed: ${response.status} ${errorText}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + + const content = data.choices[0]?.message?.content; + if (!content) { + throw new Error("LLM returned empty content"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error("LLM returned invalid JSON"); + } + + return schema.parse(parsed); +} \ No newline at end of file diff --git a/lib/schemas/index.ts b/lib/schemas/index.ts new file mode 100644 index 0000000..fa69587 --- /dev/null +++ b/lib/schemas/index.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +const intensity = z.enum(["mild", "moderate", "strong"]); +const confidence = z.enum(["high", "medium", "low"]); + +export const ratingExplainedSchema = z.object({ + officialRating: z.string(), + region: z.string(), + summary: z.string(), + factors: z.array( + z.object({ + category: z.enum([ + "violence", + "language", + "sexuality", + "substances", + "frightening", + "other", + ]), + intensity, + description: z.string(), + approximateTiming: z.string().optional(), + confidence: confidence.optional(), + }), + ), + borderlineNote: z.string().optional(), +}); + +export const skipGuideSchema = z.object({ + originalRating: z.string(), + suggestedAudienceAge: z.number().int().min(0).max(21), + totalSkippedMinutes: z.number().int().min(0), + segments: z.array( + z.object({ + start: z.string(), + end: z.string(), + reason: z.string(), + intensity, + plotImpact: z.enum(["none", "minor", "significant"]), + skipRecommendation: z.enum(["recommended", "optional", "do_not_skip"]), + resumeHint: z.string(), + }), + ), + disclaimer: z.string(), +}); + +export const sensitivityGuideSchema = z.object({ + summary: z.string(), + themes: z.array( + z.object({ + theme: z.string(), + severity: intensity, + description: z.string(), + approximateTiming: z.string().optional(), + }), + ), +}); + +export const internationalRatingsSchema = z.object({ + ratings: z.array( + z.object({ + country: z.string(), + countryCode: z.string(), + rating: z.string(), + descriptors: z.array(z.string()).optional(), + }), + ), + discrepancyNote: z.string(), + strictestCountry: z.string().optional(), + mostLenientCountry: z.string().optional(), +}); + +export const productionFactsSchema = z.object({ + facts: z.array( + z.object({ + category: z.string(), + fact: z.string(), + }), + ), + budget: z.string().optional(), + boxOffice: z.string().optional(), + filmingLocations: z.array(z.string()).optional(), +}); + +export const historicalAccuracySchema = z.object({ + accuracyScore: z.number().min(0).max(100), + summary: z.string(), + accurateElements: z.array(z.string()), + inventions: z.array( + z.object({ + claim: z.string(), + reality: z.string(), + }), + ), + confidence, +}); + +export type RatingExplained = z.infer; +export type SkipGuide = z.infer; +export type SensitivityGuide = z.infer; +export type InternationalRatings = z.infer; +export type ProductionFacts = z.infer; +export type HistoricalAccuracy = z.infer; \ No newline at end of file diff --git a/lib/tmdb.ts b/lib/tmdb.ts new file mode 100644 index 0000000..0f18f3b --- /dev/null +++ b/lib/tmdb.ts @@ -0,0 +1,157 @@ +const TMDB_BASE = "https://api.themoviedb.org/3"; +const LANGUAGE = "en-US"; + +export interface TmdbSearchResult { + id: number; + title: string; + release_date: string; + poster_path: string | null; + overview: string; +} + +export interface TmdbMovieDetails { + id: number; + title: string; + release_date: string; + runtime: number | null; + overview: string; + poster_path: string | null; + backdrop_path: string | null; + genres: Array<{ id: number; name: string }>; + budget: number; + revenue: number; + production_countries: Array<{ iso_3166_1: string; name: string }>; + production_companies: Array<{ name: string }>; + spoken_languages: Array<{ english_name: string }>; + release_dates?: { + results: Array<{ + iso_3166_1: string; + release_dates: Array<{ + certification: string; + type: number; + note: string; + }>; + }>; + }; + keywords?: { + keywords: Array<{ id: number; name: string }>; + }; + credits?: { + cast: Array<{ name: string; character: string }>; + crew: Array<{ name: string; job: string; department: string }>; + }; +} + +function getApiKey(): string { + const key = process.env.TMDB_API_KEY; + if (!key) { + throw new Error("TMDB_API_KEY is not set"); + } + return key; +} + +async function tmdbFetch(path: string, params: Record = {}) { + const url = new URL(`${TMDB_BASE}${path}`); + url.searchParams.set("api_key", getApiKey()); + url.searchParams.set("language", LANGUAGE); + + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url.toString(), { + next: { revalidate: 3600 }, + }); + + if (!response.ok) { + throw new Error(`TMDB request failed: ${response.status}`); + } + + return response.json() as Promise; +} + +export async function searchMovies(query: string): Promise { + if (!query.trim()) { + return []; + } + + const data = await tmdbFetch<{ results: TmdbSearchResult[] }>("/search/movie", { + query: query.trim(), + include_adult: "false", + }); + + return data.results.slice(0, 8); +} + +export async function getMovieDetails(movieId: number): Promise { + return tmdbFetch(`/movie/${movieId}`, { + append_to_response: "release_dates,credits,keywords", + }); +} + +export function getPosterUrl(path: string | null, size: "w185" | "w500" | "original" = "w500") { + if (!path) { + return null; + } + return `https://image.tmdb.org/t/p/${size}${path}`; +} + +export function getUsCertification(movie: TmdbMovieDetails): string | null { + const us = movie.release_dates?.results.find((r) => r.iso_3166_1 === "US"); + if (!us) { + return null; + } + + const theatrical = us.release_dates.find((r) => r.type === 3 && r.certification); + if (theatrical?.certification) { + return theatrical.certification; + } + + const any = us.release_dates.find((r) => r.certification); + return any?.certification ?? null; +} + +export function formatMovieContext(movie: TmdbMovieDetails): string { + const genres = movie.genres.map((g) => g.name).join(", "); + const keywords = movie.keywords?.keywords.map((k) => k.name).join(", ") ?? ""; + const certification = getUsCertification(movie); + const directors = movie.credits?.crew + .filter((c) => c.job === "Director") + .map((c) => c.name) + .join(", "); + const leadCast = movie.credits?.cast + .slice(0, 5) + .map((c) => `${c.name} as ${c.character}`) + .join("; "); + + const releaseDates = movie.release_dates?.results + .map((country) => { + const certs = country.release_dates + .filter((r) => r.certification) + .map((r) => r.certification); + const unique = [...new Set(certs)]; + if (unique.length === 0) { + return null; + } + return `${country.iso_3166_1}: ${unique.join(", ")}`; + }) + .filter(Boolean) + .join("\n"); + + return [ + `Title: ${movie.title}`, + `Year: ${movie.release_date?.slice(0, 4) ?? "Unknown"}`, + `Runtime: ${movie.runtime ?? "Unknown"} minutes`, + `Genres: ${genres}`, + `Overview: ${movie.overview}`, + certification ? `US Certification: ${certification}` : null, + keywords ? `Keywords: ${keywords}` : null, + directors ? `Director(s): ${directors}` : null, + leadCast ? `Lead cast: ${leadCast}` : null, + movie.budget ? `Budget: $${movie.budget.toLocaleString("en-US")}` : null, + movie.revenue ? `Revenue: $${movie.revenue.toLocaleString("en-US")}` : null, + releaseDates ? `International certifications:\n${releaseDates}` : null, + ] + .filter(Boolean) + .join("\n"); +} \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..daab5de --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..76db125 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "image.tmdb.org", + pathname: "/t/p/**", + }, + ], + }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1d17a4b..df58081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,28 @@ "name": "film", "version": "0.1.0", "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.17.0", "next": "16.2.9", + "postgres": "^3.4.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", + "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.9", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^5" } }, @@ -276,6 +286,13 @@ "node": ">=6.9.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -309,6 +326,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1641,12 +2536,24 @@ "version": "20.19.42", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -2679,6 +3586,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2776,12 +3690,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2983,6 +3918,147 @@ "node": ">=0.10.0" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3203,6 +4279,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3773,6 +4891,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5043,6 +6176,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5499,6 +6641,40 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5557,6 +6733,62 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6038,6 +7270,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6047,6 +7289,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6254,6 +7507,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -6381,6 +7644,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6533,7 +8299,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6730,6 +8496,16 @@ "node": ">=0.10.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6754,7 +8530,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 14a90d8..b0205a3 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,32 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "db:migrate": "tsx scripts/migrate.ts" }, "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.17.0", "next": "16.2.9", + "postgres": "^3.4.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", + "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.9", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^5" } } diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..b39bdf3 --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,22 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import postgres from "postgres"; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} + +const sql = postgres(connectionString, { max: 1 }); +const migrationPath = join(process.cwd(), "drizzle/migrations/0000_init.sql"); +const migration = readFileSync(migrationPath, "utf-8"); + +try { + await sql.unsafe(migration); + await sql.end(); + console.log("Migrations applied successfully"); +} catch (error) { + console.error("Migration failed:", error); + process.exit(1); +} \ No newline at end of file diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..19fc99b --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import postgres from "postgres"; + +async function migrate() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error("DATABASE_URL is not set"); + } + + const sql = postgres(connectionString, { max: 1 }); + const migrationPath = join(process.cwd(), "drizzle/migrations/0000_init.sql"); + const migration = readFileSync(migrationPath, "utf-8"); + + await sql.unsafe(migration); + await sql.end(); + + console.log("Migrations applied successfully"); +} + +migrate().catch((error) => { + console.error("Migration failed:", error); + process.exit(1); +}); \ No newline at end of file