build complete app with Supabase, auth, and feature pages

This commit is contained in:
v0 2026-02-07 01:21:37 +00:00
parent 94f2f8fd08
commit dab373a1d9
25 changed files with 2364 additions and 2 deletions

35
app/(app)/diary/page.tsx Normal file
View File

@ -0,0 +1,35 @@
import { createClient } from "@/lib/supabase/server"
import { DiaryContent } from "@/components/diary-content"
export default async function DiaryPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
const { data: entries } = await supabase
.from("diary_entries")
.select("*")
.eq("user_id", user!.id)
.order("watched_at", { ascending: false })
const { data: watchlist } = await supabase
.from("watchlist")
.select("*")
.eq("user_id", user!.id)
.order("added_at", { ascending: false })
const { data: lists } = await supabase
.from("lists")
.select("*, list_items(count)")
.eq("user_id", user!.id)
.order("created_at", { ascending: false })
return (
<DiaryContent
entries={entries || []}
watchlist={watchlist || []}
lists={lists || []}
/>
)
}

38
app/(app)/feed/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import { createClient } from "@/lib/supabase/server"
import { getTrending } from "@/lib/tmdb"
import { FeedContent } from "@/components/feed-content"
export default async function FeedPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user!.id)
.single()
// Get family diary entries with profiles
const { data: entries } = await supabase
.from("diary_entries")
.select("*, profiles(display_name, avatar_url)")
.order("created_at", { ascending: false })
.limit(20)
// Get trending movies
let trending = { results: [] }
try {
trending = await getTrending()
} catch {
// TMDB might fail, show empty
}
return (
<FeedContent
profile={profile}
entries={entries || []}
trending={trending.results?.slice(0, 10) || []}
/>
)
}

11
app/(app)/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from "react"
import { BottomNav } from "@/components/bottom-nav"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-dvh pb-20">
{children}
<BottomNav />
</div>
)
}

View File

@ -0,0 +1,28 @@
import { createClient } from "@/lib/supabase/server"
import { ListDetailContent } from "@/components/list-detail-content"
import { redirect } from "next/navigation"
export default async function ListDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const { data: list } = await supabase
.from("lists")
.select("*")
.eq("id", id)
.single()
if (!list) redirect("/diary")
const { data: items } = await supabase
.from("list_items")
.select("*")
.eq("list_id", id)
.order("added_at", { ascending: false })
return <ListDetailContent list={list} items={items || []} />
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

304
app/(app)/log/page.tsx Normal file
View File

@ -0,0 +1,304 @@
"use client"
import React from "react"
import { useState, useEffect, useCallback, Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Image from "next/image"
import { createClient } from "@/lib/supabase/client"
import { posterUrl } from "@/lib/tmdb"
import type { TMDBMovie } from "@/lib/tmdb"
import { StarRating } from "@/components/star-rating"
import { Search, Film, Check, Loader2 } from "lucide-react"
function LogPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const preselectedId = searchParams.get("tmdb_id")
const preselectedTitle = searchParams.get("title")
const preselectedPoster = searchParams.get("poster_path")
const [step, setStep] = useState<"search" | "rate">(
preselectedId ? "rate" : "search"
)
const [query, setQuery] = useState("")
const [results, setResults] = useState<TMDBMovie[]>([])
const [searching, setSearching] = useState(false)
const [selectedMovie, setSelectedMovie] = useState<{
id: number
title: string
poster_path: string | null
} | null>(
preselectedId
? {
id: Number(preselectedId),
title: preselectedTitle || "",
poster_path: preselectedPoster || null,
}
: null
)
const [rating, setRating] = useState(0)
const [review, setReview] = useState("")
const [watchedAt, setWatchedAt] = useState(
new Date().toISOString().slice(0, 10)
)
const [saving, setSaving] = useState(false)
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([])
return
}
setSearching(true)
try {
const res = await fetch(`/api/tmdb/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.results || [])
} catch {
setResults([])
}
setSearching(false)
}, [])
useEffect(() => {
const timer = setTimeout(() => doSearch(query), 400)
return () => clearTimeout(timer)
}, [query, doSearch])
function selectMovie(movie: TMDBMovie) {
setSelectedMovie({
id: movie.id,
title: movie.title,
poster_path: movie.poster_path,
})
setStep("rate")
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!selectedMovie) return
setSaving(true)
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { error } = await supabase.from("diary_entries").insert({
user_id: user.id,
tmdb_id: selectedMovie.id,
title: selectedMovie.title,
poster_path: selectedMovie.poster_path,
rating: rating || null,
review: review.trim() || null,
watched_at: watchedAt,
})
if (!error) {
router.push("/diary")
router.refresh()
}
setSaving(false)
}
const posterImage = selectedMovie?.poster_path
? posterUrl(selectedMovie.poster_path, "w342")
: null
if (step === "search") {
return (
<main className="mx-auto max-w-lg">
<header className="sticky top-0 z-40 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
<h1 className="mb-3 font-heading text-lg font-bold text-foreground">
Film loggen
</h1>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Welchen Film hast du gesehen?"
className="h-11 w-full rounded-lg border border-border bg-secondary pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
</div>
</header>
<div className="px-4 pt-4">
{searching && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!searching && results.length === 0 && query.trim() && (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Filme gefunden
</p>
)}
<div className="flex flex-col gap-2">
{results.map((movie) => {
const url = posterUrl(movie.poster_path, "w185")
return (
<button
key={movie.id}
onClick={() => selectMovie(movie)}
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 text-left transition-colors hover:bg-secondary"
type="button"
>
<div className="relative h-16 w-11 shrink-0 overflow-hidden rounded bg-secondary">
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={movie.title}
fill
className="object-cover"
sizes="44px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
<div>
<p className="text-sm font-medium text-foreground">
{movie.title}
</p>
<p className="text-xs text-muted-foreground">
{movie.release_date?.slice(0, 4)}
</p>
</div>
</button>
)
})}
</div>
</div>
</main>
)
}
return (
<main className="mx-auto max-w-lg">
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
<button
onClick={() => {
setStep("search")
setSelectedMovie(null)
}}
className="text-sm text-muted-foreground hover:text-foreground"
type="button"
>
Anderen Film
</button>
<h1 className="font-heading text-lg font-bold text-foreground">
Bewerten
</h1>
<div className="w-16" />
</header>
<form onSubmit={handleSubmit} className="px-4 pt-6">
{/* Selected movie */}
<div className="flex gap-4">
<div className="relative h-32 w-[86px] shrink-0 overflow-hidden rounded-lg bg-secondary">
{posterImage ? (
<Image
src={posterImage || "/placeholder.svg"}
alt={selectedMovie?.title || ""}
fill
className="object-cover"
sizes="86px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
<div className="flex flex-col justify-center">
<h2 className="font-heading text-lg font-bold text-foreground">
{selectedMovie?.title}
</h2>
</div>
</div>
{/* Rating */}
<div className="mt-6">
<label className="mb-2 block text-sm font-medium text-foreground">
Deine Bewertung
</label>
<StarRating
rating={rating}
size="lg"
interactive
onChange={setRating}
/>
</div>
{/* Watch date */}
<div className="mt-5">
<label
htmlFor="watchedAt"
className="mb-2 block text-sm font-medium text-foreground"
>
Gesehen am
</label>
<input
id="watchedAt"
type="date"
value={watchedAt}
onChange={(e) => setWatchedAt(e.target.value)}
className="h-11 w-full rounded-lg border border-border bg-secondary px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Review */}
<div className="mt-5">
<label
htmlFor="review"
className="mb-2 block text-sm font-medium text-foreground"
>
Review (optional)
</label>
<textarea
id="review"
value={review}
onChange={(e) => setReview(e.target.value)}
placeholder="Was denkst du ueber den Film?"
rows={4}
className="w-full rounded-lg border border-border bg-secondary px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<button
type="submit"
disabled={saving}
className="mt-6 flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{saving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Check className="h-5 w-5" />
Film loggen
</>
)}
</button>
</form>
<div className="h-8" />
</main>
)
}
export default function LogPage() {
return (
<Suspense fallback={<div className="flex min-h-dvh items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}>
<LogPageContent />
</Suspense>
)
}

View File

@ -0,0 +1,40 @@
import { getMovie } from "@/lib/tmdb"
import { createClient } from "@/lib/supabase/server"
import { MovieDetail } from "@/components/movie-detail"
export default async function MoviePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const movie = await getMovie(Number(id))
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
// Check if in watchlist
const { data: watchlistItem } = await supabase
.from("watchlist")
.select("id")
.eq("user_id", user!.id)
.eq("tmdb_id", movie.id)
.maybeSingle()
// Get diary entries for this movie from family
const { data: familyEntries } = await supabase
.from("diary_entries")
.select("*, profiles(display_name)")
.eq("tmdb_id", movie.id)
.order("created_at", { ascending: false })
return (
<MovieDetail
movie={movie}
isInWatchlist={!!watchlistItem}
familyEntries={familyEntries || []}
/>
)
}

View File

@ -0,0 +1,42 @@
import { createClient } from "@/lib/supabase/server"
import { ProfileContent } from "@/components/profile-content"
export default async function ProfilePage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user!.id)
.single()
const { count: diaryCount } = await supabase
.from("diary_entries")
.select("*", { count: "exact", head: true })
.eq("user_id", user!.id)
const { count: watchlistCount } = await supabase
.from("watchlist")
.select("*", { count: "exact", head: true })
.eq("user_id", user!.id)
const { count: listsCount } = await supabase
.from("lists")
.select("*", { count: "exact", head: true })
.eq("user_id", user!.id)
return (
<ProfileContent
profile={profile}
email={user!.email || ""}
stats={{
diary: diaryCount || 0,
watchlist: watchlistCount || 0,
lists: listsCount || 0,
}}
/>
)
}

132
app/(app)/search/page.tsx Normal file
View File

@ -0,0 +1,132 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import Image from "next/image"
import Link from "next/link"
import { Search, Film, Loader2 } from "lucide-react"
import { posterUrl } from "@/lib/tmdb"
import type { TMDBMovie } from "@/lib/tmdb"
export default function SearchPage() {
const [query, setQuery] = useState("")
const [results, setResults] = useState<TMDBMovie[]>([])
const [trending, setTrending] = useState<TMDBMovie[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
fetch("/api/tmdb/trending")
.then((r) => r.json())
.then((data) => setTrending(data.results || []))
.catch(() => {})
}, [])
const searchMovies = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([])
return
}
setLoading(true)
try {
const res = await fetch(`/api/tmdb/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.results || [])
} catch {
setResults([])
}
setLoading(false)
}, [])
useEffect(() => {
const timer = setTimeout(() => searchMovies(query), 400)
return () => clearTimeout(timer)
}, [query, searchMovies])
const moviesToShow = query.trim() ? results : trending
return (
<main className="mx-auto max-w-lg">
<header className="sticky top-0 z-40 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filme suchen..."
className="h-11 w-full rounded-lg border border-border bg-secondary pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
</div>
</header>
<div className="px-4 pt-4">
{!query.trim() && trending.length > 0 && (
<h2 className="mb-3 font-heading text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Beliebt gerade
</h2>
)}
{query.trim() && results.length === 0 && !loading && (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<Film className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Keine Filme gefunden
</p>
</div>
)}
<div className="flex flex-col gap-3">
{moviesToShow.map((movie) => (
<SearchResultCard key={movie.id} movie={movie} />
))}
</div>
</div>
<div className="h-8" />
</main>
)
}
function SearchResultCard({ movie }: { movie: TMDBMovie }) {
const url = posterUrl(movie.poster_path, "w185")
return (
<Link
href={`/movie/${movie.id}`}
className="flex gap-3 rounded-xl border border-border bg-card p-3 transition-colors hover:bg-secondary"
>
<div className="relative h-24 w-16 shrink-0 overflow-hidden rounded-lg bg-secondary">
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={movie.title}
fill
className="object-cover"
sizes="64px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-5 w-5 text-muted-foreground" />
</div>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-1">
<h3 className="truncate font-heading text-sm font-semibold text-foreground">
{movie.title}
</h3>
{movie.release_date && (
<p className="text-xs text-muted-foreground">
{movie.release_date.slice(0, 4)}
</p>
)}
{movie.overview && (
<p className="line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
{movie.overview}
</p>
)}
</div>
</Link>
)
}

View File

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server"
import { getMovie } from "@/lib/tmdb"
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const data = await getMovie(Number(id))
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: "Film nicht gefunden" }, { status: 500 })
}
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server"
import { searchMovies } from "@/lib/tmdb"
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("q")
const page = request.nextUrl.searchParams.get("page") || "1"
if (!query) {
return NextResponse.json({ results: [] })
}
try {
const data = await searchMovies(query, Number(page))
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: "Suche fehlgeschlagen" }, { status: 500 })
}
}

View File

@ -0,0 +1,11 @@
import { NextResponse } from "next/server"
import { getTrending } from "@/lib/tmdb"
export async function GET() {
try {
const data = await getTrending()
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: "Trends konnten nicht geladen werden" }, { status: 500 })
}
}

29
app/auth/error/page.tsx Normal file
View File

@ -0,0 +1,29 @@
import Link from "next/link"
import { AlertTriangle, ArrowLeft } from "lucide-react"
export default function AuthErrorPage() {
return (
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
</div>
<h1 className="font-heading text-2xl font-bold text-foreground">
Fehler bei der Anmeldung
</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Etwas ist schiefgelaufen. Bitte versuche es erneut.
</p>
<Link
href="/auth/login"
className="mt-8 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Zur Anmeldeseite
</Link>
</div>
</main>
)
}

114
app/auth/login/page.tsx Normal file
View File

@ -0,0 +1,114 @@
"use client"
import React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { createClient } from "@/lib/supabase/client"
import { Film, Eye, Loader2 } from "lucide-react"
export default function LoginPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError("E-Mail oder Passwort falsch.")
setLoading(false)
return
}
router.push("/feed")
router.refresh()
}
return (
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
<div className="w-full max-w-sm">
<div className="mb-10 flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
<Film className="h-7 w-7 text-primary-foreground" />
</div>
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground">
InFocus
</h1>
<p className="text-sm text-muted-foreground">
Familienfilm-Tagebuch
</p>
</div>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-medium text-foreground">
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="familie@example.com"
required
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="password" className="text-sm font-medium text-foreground">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
required
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="mt-2 flex h-12 items-center justify-center gap-2 rounded-lg bg-primary font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Eye className="h-5 w-5" />
Anmelden
</>
)}
</button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Noch kein Konto?{" "}
<Link href="/auth/sign-up" className="font-medium text-primary hover:underline">
Registrieren
</Link>
</p>
</div>
</main>
)
}

View File

@ -0,0 +1,30 @@
import Link from "next/link"
import { Mail, ArrowLeft } from "lucide-react"
export default function SignUpSuccessPage() {
return (
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-8 w-8 text-primary" />
</div>
</div>
<h1 className="font-heading text-2xl font-bold text-foreground">
E-Mail pruefen
</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Wir haben dir eine Best&auml;tigungs-E-Mail geschickt. Klicke auf den Link in
der E-Mail, um dein Konto zu aktivieren.
</p>
<Link
href="/auth/login"
className="mt-8 inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Zur Anmeldeseite
</Link>
</div>
</main>
)
}

138
app/auth/sign-up/page.tsx Normal file
View File

@ -0,0 +1,138 @@
"use client"
import React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { createClient } from "@/lib/supabase/client"
import { Film, UserPlus, Loader2 } from "lucide-react"
export default function SignUpPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [displayName, setDisplayName] = useState("")
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSignUp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
const supabase = createClient()
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo:
process.env.NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL ||
`${window.location.origin}/feed`,
data: {
display_name: displayName,
},
},
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push("/auth/sign-up-success")
}
return (
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
<div className="w-full max-w-sm">
<div className="mb-10 flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
<Film className="h-7 w-7 text-primary-foreground" />
</div>
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground">
Konto erstellen
</h1>
<p className="text-sm text-muted-foreground">
Tritt deinem Familien-Filmclub bei
</p>
</div>
<form onSubmit={handleSignUp} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="displayName" className="text-sm font-medium text-foreground">
Anzeigename
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="z.B. Papa, Mama, Lisa..."
required
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-medium text-foreground">
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="familie@example.com"
required
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="password" className="text-sm font-medium text-foreground">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
required
minLength={6}
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="mt-2 flex h-12 items-center justify-center gap-2 rounded-lg bg-primary font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<UserPlus className="h-5 w-5" />
Registrieren
</>
)}
</button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Bereits ein Konto?{" "}
<Link href="/auth/login" className="font-medium text-primary hover:underline">
Anmelden
</Link>
</p>
</div>
</main>
)
}

View File

@ -1,3 +1,15 @@
export default function Page() {
return null
import { redirect } from "next/navigation"
import { createClient } from "@/lib/supabase/server"
export default async function Page() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (user) {
redirect("/feed")
} else {
redirect("/auth/login")
}
}

50
components/bottom-nav.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, Search, PlusCircle, BookOpen, User } from "lucide-react"
const navItems = [
{ href: "/feed", label: "Feed", icon: Home },
{ href: "/search", label: "Suche", icon: Search },
{ href: "/log", label: "Loggen", icon: PlusCircle },
{ href: "/diary", label: "Tagebuch", icon: BookOpen },
{ href: "/profile", label: "Profil", icon: User },
]
export function BottomNav() {
const pathname = usePathname()
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background/95 backdrop-blur-md safe-bottom">
<div className="mx-auto flex max-w-lg items-center justify-around py-2">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/")
const Icon = item.icon
const isLogButton = item.href === "/log"
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-1 px-3 py-1 transition-colors ${
isLogButton
? "text-primary"
: isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon
className={`${isLogButton ? "h-7 w-7" : "h-5 w-5"}`}
strokeWidth={isActive || isLogButton ? 2.5 : 1.5}
/>
<span className="text-[10px] font-medium">{item.label}</span>
</Link>
)
})}
</div>
</nav>
)
}

View File

@ -0,0 +1,368 @@
"use client"
import React from "react"
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { posterUrl } from "@/lib/tmdb"
import { StarRating } from "@/components/star-rating"
import { createClient } from "@/lib/supabase/client"
import { useRouter } from "next/navigation"
import {
BookOpen,
Bookmark,
ListIcon,
Film,
Trash2,
Plus,
Loader2,
} from "lucide-react"
interface DiaryEntry {
id: string
tmdb_id: number
title: string
poster_path: string | null
rating: number | null
review: string | null
watched_at: string
}
interface WatchlistItem {
id: string
tmdb_id: number
title: string
poster_path: string | null
added_at: string
}
interface ListItem {
id: string
name: string
description: string | null
list_items: { count: number }[]
}
interface DiaryContentProps {
entries: DiaryEntry[]
watchlist: WatchlistItem[]
lists: ListItem[]
}
type Tab = "diary" | "watchlist" | "lists"
export function DiaryContent({
entries,
watchlist,
lists: initialLists,
}: DiaryContentProps) {
const [activeTab, setActiveTab] = useState<Tab>("diary")
const [lists, setLists] = useState(initialLists)
const [showNewList, setShowNewList] = useState(false)
const [newListName, setNewListName] = useState("")
const [creatingList, setCreatingList] = useState(false)
const router = useRouter()
const tabs: { id: Tab; label: string; icon: typeof BookOpen; count: number }[] = [
{ id: "diary", label: "Tagebuch", icon: BookOpen, count: entries.length },
{ id: "watchlist", label: "Watchlist", icon: Bookmark, count: watchlist.length },
{ id: "lists", label: "Listen", icon: ListIcon, count: lists.length },
]
async function createList(e: React.FormEvent) {
e.preventDefault()
if (!newListName.trim()) return
setCreatingList(true)
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { data, error } = await supabase
.from("lists")
.insert({ user_id: user.id, name: newListName.trim() })
.select("*, list_items(count)")
.single()
if (!error && data) {
setLists([data, ...lists])
setNewListName("")
setShowNewList(false)
}
setCreatingList(false)
}
async function removeFromWatchlist(id: string) {
const supabase = createClient()
await supabase.from("watchlist").delete().eq("id", id)
router.refresh()
}
async function deleteDiaryEntry(id: string) {
const supabase = createClient()
await supabase.from("diary_entries").delete().eq("id", id)
router.refresh()
}
return (
<main className="mx-auto max-w-lg">
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur-md">
<h1 className="px-4 pt-3 pb-2 font-heading text-xl font-bold text-foreground">
Meine Filme
</h1>
<div className="flex px-4">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex flex-1 items-center justify-center gap-1.5 border-b-2 pb-2.5 pt-1 text-xs font-medium transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
type="button"
>
<Icon className="h-4 w-4" />
{tab.label}
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px]">
{tab.count}
</span>
</button>
)
})}
</div>
</header>
<div className="px-4 pt-4">
{/* Diary Tab */}
{activeTab === "diary" && (
<>
{entries.length === 0 ? (
<EmptyState
icon={BookOpen}
title="Noch keine Eintraege"
description="Logge deinen ersten Film!"
href="/log"
/>
) : (
<div className="flex flex-col gap-3">
{entries.map((entry) => {
const url = posterUrl(entry.poster_path, "w185")
return (
<div
key={entry.id}
className="flex gap-3 rounded-xl border border-border bg-card p-3"
>
<Link href={`/movie/${entry.tmdb_id}`} className="shrink-0">
<div className="relative h-20 w-[54px] overflow-hidden rounded-lg bg-secondary">
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={entry.title}
fill
className="object-cover"
sizes="54px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
</Link>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
<Link href={`/movie/${entry.tmdb_id}`}>
<h3 className="truncate text-sm font-semibold text-foreground">
{entry.title}
</h3>
</Link>
<p className="text-[10px] text-muted-foreground">
{new Date(entry.watched_at).toLocaleDateString(
"de-DE",
{ day: "numeric", month: "long", year: "numeric" }
)}
</p>
{entry.rating && (
<StarRating rating={entry.rating} size="sm" />
)}
</div>
<button
onClick={() => deleteDiaryEntry(entry.id)}
className="self-start p-1 text-muted-foreground hover:text-destructive"
type="button"
aria-label="Eintrag loeschen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
})}
</div>
)}
</>
)}
{/* Watchlist Tab */}
{activeTab === "watchlist" && (
<>
{watchlist.length === 0 ? (
<EmptyState
icon={Bookmark}
title="Watchlist leer"
description="Merke dir Filme, die du noch sehen willst"
href="/search"
/>
) : (
<div className="grid grid-cols-3 gap-3">
{watchlist.map((item) => {
const url = posterUrl(item.poster_path, "w185")
return (
<div key={item.id} className="group relative">
<Link href={`/movie/${item.tmdb_id}`}>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg bg-secondary">
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={item.title}
fill
className="object-cover"
sizes="33vw"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
<p className="mt-1.5 truncate text-xs font-medium text-foreground">
{item.title}
</p>
</Link>
<button
onClick={() => removeFromWatchlist(item.id)}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
type="button"
aria-label="Von Watchlist entfernen"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
</>
)}
{/* Lists Tab */}
{activeTab === "lists" && (
<>
<button
onClick={() => setShowNewList(true)}
className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
type="button"
>
<Plus className="h-4 w-4" />
Neue Liste erstellen
</button>
{showNewList && (
<form
onSubmit={createList}
className="mb-4 flex gap-2"
>
<input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="Listenname..."
className="h-10 flex-1 rounded-lg border border-border bg-secondary px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
<button
type="submit"
disabled={creatingList}
className="flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50"
>
{creatingList ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Erstellen"
)}
</button>
</form>
)}
{lists.length === 0 && !showNewList ? (
<EmptyState
icon={ListIcon}
title="Keine Listen"
description="Erstelle deine erste Film-Liste"
/>
) : (
<div className="flex flex-col gap-3">
{lists.map((list) => (
<Link
key={list.id}
href={`/lists/${list.id}`}
className="flex items-center justify-between rounded-xl border border-border bg-card p-4 transition-colors hover:bg-secondary"
>
<div>
<h3 className="text-sm font-semibold text-foreground">
{list.name}
</h3>
{list.description && (
<p className="mt-0.5 text-xs text-muted-foreground">
{list.description}
</p>
)}
</div>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground">
{list.list_items?.[0]?.count || 0} Filme
</span>
</Link>
))}
</div>
)}
</>
)}
</div>
<div className="h-8" />
</main>
)
}
function EmptyState({
icon: Icon,
title,
description,
href,
}: {
icon: typeof BookOpen
title: string
description: string
href?: string
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-card px-6 py-12 text-center">
<Icon className="h-10 w-10 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
{href && (
<Link
href={href}
className="mt-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground"
>
Los geht&apos;s
</Link>
)}
</div>
)
}

214
components/feed-content.tsx Normal file
View File

@ -0,0 +1,214 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { posterUrl } from "@/lib/tmdb"
import { StarRating } from "@/components/star-rating"
import { MovieCard } from "@/components/movie-card"
import { Heart, MessageCircle, Film, Clock } from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import { useState } from "react"
interface FeedEntry {
id: string
user_id: string
tmdb_id: number
title: string
poster_path: string | null
rating: number | null
review: string | null
watched_at: string
created_at: string
profiles: {
display_name: string
avatar_url: string | null
}
}
interface TrendingMovie {
id: number
title: string
poster_path: string | null
release_date?: string
}
interface FeedContentProps {
profile: { display_name: string; avatar_url: string | null } | null
entries: FeedEntry[]
trending: TrendingMovie[]
}
export function FeedContent({ profile, entries, trending }: FeedContentProps) {
return (
<main className="mx-auto max-w-lg">
{/* Header */}
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
<div className="flex items-center gap-2">
<Film className="h-6 w-6 text-primary" />
<h1 className="font-heading text-xl font-bold text-foreground">
InFocus
</h1>
</div>
<p className="text-sm text-muted-foreground">
Hallo, {profile?.display_name || "Film-Fan"}
</p>
</header>
{/* Trending Section */}
{trending.length > 0 && (
<section className="px-4 pt-5 pb-2">
<h2 className="mb-3 font-heading text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Trending diese Woche
</h2>
<div className="hide-scrollbar flex gap-3 overflow-x-auto pb-2">
{trending.map((movie) => (
<MovieCard
key={movie.id}
tmdbId={movie.id}
title={movie.title}
posterPath={movie.poster_path}
subtitle={movie.release_date?.slice(0, 4)}
size="sm"
/>
))}
</div>
</section>
)}
{/* Family Feed */}
<section className="px-4 pt-4">
<h2 className="mb-4 font-heading text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Familien-Aktivit&auml;t
</h2>
{entries.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-card px-6 py-12 text-center">
<Clock className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Noch keine Eintr&auml;ge. Logge deinen ersten Film!
</p>
<Link
href="/log"
className="mt-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground"
>
Film loggen
</Link>
</div>
) : (
<div className="flex flex-col gap-4">
{entries.map((entry) => (
<FeedCard key={entry.id} entry={entry} />
))}
</div>
)}
</section>
<div className="h-8" />
</main>
)
}
function FeedCard({ entry }: { entry: FeedEntry }) {
const [liked, setLiked] = useState(false)
const [likeCount, setLikeCount] = useState(0)
const url = posterUrl(entry.poster_path, "w185")
async function toggleLike() {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
if (liked) {
await supabase
.from("likes")
.delete()
.eq("user_id", user.id)
.eq("diary_entry_id", entry.id)
setLiked(false)
setLikeCount((c) => Math.max(0, c - 1))
} else {
await supabase.from("likes").insert({
user_id: user.id,
diary_entry_id: entry.id,
})
setLiked(true)
setLikeCount((c) => c + 1)
}
}
const watchedDate = new Date(entry.watched_at).toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
})
return (
<article className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex gap-3 p-4">
{/* Poster */}
<Link href={`/movie/${entry.tmdb_id}`} className="shrink-0">
<div className="relative h-28 w-[75px] overflow-hidden rounded-lg bg-secondary">
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={entry.title}
fill
className="object-cover"
sizes="75px"
/>
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
</Link>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-[10px] font-bold text-primary">
{entry.profiles?.display_name?.charAt(0).toUpperCase() || "?"}
</div>
<span className="text-xs font-medium text-foreground">
{entry.profiles?.display_name}
</span>
<span className="text-[10px] text-muted-foreground">
{watchedDate}
</span>
</div>
<Link href={`/movie/${entry.tmdb_id}`}>
<h3 className="truncate font-heading text-sm font-semibold text-foreground">
{entry.title}
</h3>
</Link>
{entry.rating && <StarRating rating={entry.rating} size="sm" />}
{entry.review && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{entry.review}
</p>
)}
{/* Actions */}
<div className="mt-1 flex items-center gap-4">
<button
onClick={toggleLike}
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-primary"
type="button"
>
<Heart
className={`h-4 w-4 ${liked ? "fill-primary text-primary" : ""}`}
/>
{likeCount > 0 && (
<span className="text-[10px]">{likeCount}</span>
)}
</button>
</div>
</div>
</div>
</article>
)
}

View File

@ -0,0 +1,233 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { createClient } from "@/lib/supabase/client"
import { posterUrl } from "@/lib/tmdb"
import type { TMDBMovie } from "@/lib/tmdb"
import {
ArrowLeft,
Plus,
Search,
Film,
Trash2,
X,
Loader2,
Share2,
} from "lucide-react"
interface ListItem {
id: string
tmdb_id: number
title: string
poster_path: string | null
}
interface ListDetailContentProps {
list: { id: string; name: string; description: string | null }
items: ListItem[]
}
export function ListDetailContent({
list,
items: initialItems,
}: ListDetailContentProps) {
const [items, setItems] = useState(initialItems)
const [showSearch, setShowSearch] = useState(false)
const [query, setQuery] = useState("")
const [searchResults, setSearchResults] = useState<TMDBMovie[]>([])
const [searching, setSearching] = useState(false)
const router = useRouter()
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setSearchResults([])
return
}
setSearching(true)
try {
const res = await fetch(`/api/tmdb/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setSearchResults(data.results || [])
} catch {
setSearchResults([])
}
setSearching(false)
}, [])
useEffect(() => {
const timer = setTimeout(() => doSearch(query), 400)
return () => clearTimeout(timer)
}, [query, doSearch])
async function addToList(movie: TMDBMovie) {
const supabase = createClient()
const { data, error } = await supabase
.from("list_items")
.insert({
list_id: list.id,
tmdb_id: movie.id,
title: movie.title,
poster_path: movie.poster_path,
})
.select()
.single()
if (!error && data) {
setItems([data, ...items])
setShowSearch(false)
setQuery("")
}
}
async function removeItem(id: string) {
const supabase = createClient()
await supabase.from("list_items").delete().eq("id", id)
setItems(items.filter((i) => i.id !== id))
}
async function handleShare() {
const text = `Schau dir meine Filmliste "${list.name}" an: ${items.map((i) => i.title).join(", ")}`
if (navigator.share) {
try {
await navigator.share({ title: list.name, text, url: window.location.href })
} catch { /* cancelled */ }
} else {
await navigator.clipboard.writeText(text)
}
}
return (
<main className="mx-auto max-w-lg">
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
<button onClick={() => router.back()} type="button" aria-label="Zurueck">
<ArrowLeft className="h-5 w-5 text-foreground" />
</button>
<h1 className="font-heading text-lg font-bold text-foreground">
{list.name}
</h1>
<button onClick={handleShare} type="button" aria-label="Teilen">
<Share2 className="h-5 w-5 text-foreground" />
</button>
</header>
<div className="px-4 pt-4">
{/* Add movie */}
{showSearch ? (
<div className="mb-4">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Film zur Liste hinzufuegen..."
className="h-10 w-full rounded-lg border border-border bg-secondary pl-9 pr-9 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
<button
onClick={() => {
setShowSearch(false)
setQuery("")
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
{searching && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{searchResults.map((movie) => {
const url = posterUrl(movie.poster_path, "w185")
const alreadyAdded = items.some((i) => i.tmdb_id === movie.id)
return (
<button
key={movie.id}
onClick={() => !alreadyAdded && addToList(movie)}
disabled={alreadyAdded}
className="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-secondary disabled:opacity-50"
type="button"
>
<div className="relative h-12 w-8 shrink-0 overflow-hidden rounded bg-secondary">
{url ? (
<Image src={url || "/placeholder.svg"} alt={movie.title} fill className="object-cover" sizes="32px" />
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-3 w-3 text-muted-foreground" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{movie.title}</p>
<p className="text-xs text-muted-foreground">{movie.release_date?.slice(0, 4)}</p>
</div>
{alreadyAdded && (
<span className="text-xs text-muted-foreground">Bereits drin</span>
)}
</button>
)
})}
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
type="button"
>
<Plus className="h-4 w-4" />
Film hinzufuegen
</button>
)}
{/* List items */}
{items.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<Film className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Diese Liste ist noch leer
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{items.map((item) => {
const url = posterUrl(item.poster_path, "w185")
return (
<div key={item.id} className="group relative">
<Link href={`/movie/${item.tmdb_id}`}>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg bg-secondary">
{url ? (
<Image src={url || "/placeholder.svg"} alt={item.title} fill className="object-cover" sizes="33vw" />
) : (
<div className="flex h-full items-center justify-center">
<Film className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
<p className="mt-1.5 truncate text-xs font-medium text-foreground">
{item.title}
</p>
</Link>
<button
onClick={() => removeItem(item.id)}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
type="button"
aria-label="Entfernen"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
</div>
<div className="h-8" />
</main>
)
}

60
components/movie-card.tsx Normal file
View File

@ -0,0 +1,60 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { posterUrl } from "@/lib/tmdb"
import { Film } from "lucide-react"
interface MovieCardProps {
tmdbId: number
title: string
posterPath: string | null
subtitle?: string
size?: "sm" | "md" | "lg"
}
const sizeClasses = {
sm: "w-24 h-36",
md: "w-32 h-48",
lg: "w-40 h-60",
}
export function MovieCard({
tmdbId,
title,
posterPath,
subtitle,
size = "md",
}: MovieCardProps) {
const url = posterUrl(posterPath, size === "sm" ? "w185" : "w342")
return (
<Link href={`/movie/${tmdbId}`} className="group flex flex-col gap-2">
<div
className={`${sizeClasses[size]} relative overflow-hidden rounded-lg bg-secondary transition-transform group-hover:scale-105`}
>
{url ? (
<Image
src={url || "/placeholder.svg"}
alt={title}
fill
className="object-cover"
sizes={size === "sm" ? "96px" : size === "md" ? "128px" : "160px"}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
)}
</div>
<div className={`${sizeClasses[size].split(" ")[0]}`}>
<p className="truncate text-xs font-medium text-foreground">{title}</p>
{subtitle && (
<p className="truncate text-[10px] text-muted-foreground">
{subtitle}
</p>
)}
</div>
</Link>
)
}

265
components/movie-detail.tsx Normal file
View File

@ -0,0 +1,265 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { posterUrl, backdropUrl } from "@/lib/tmdb"
import type { TMDBMovieDetail } from "@/lib/tmdb"
import { StarRating } from "@/components/star-rating"
import { createClient } from "@/lib/supabase/client"
import {
ArrowLeft,
Bookmark,
BookmarkCheck,
PenLine,
Clock,
Film,
Share2,
} from "lucide-react"
interface FamilyEntry {
id: string
rating: number | null
review: string | null
watched_at: string
profiles: { display_name: string }
}
interface MovieDetailProps {
movie: TMDBMovieDetail
isInWatchlist: boolean
familyEntries: FamilyEntry[]
}
export function MovieDetail({
movie,
isInWatchlist: initialWatchlist,
familyEntries,
}: MovieDetailProps) {
const [inWatchlist, setInWatchlist] = useState(initialWatchlist)
const [saving, setSaving] = useState(false)
const router = useRouter()
const backdrop = backdropUrl(movie.backdrop_path, "w1280")
const poster = posterUrl(movie.poster_path, "w500")
async function toggleWatchlist() {
setSaving(true)
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
if (inWatchlist) {
await supabase
.from("watchlist")
.delete()
.eq("user_id", user.id)
.eq("tmdb_id", movie.id)
setInWatchlist(false)
} else {
await supabase.from("watchlist").insert({
user_id: user.id,
tmdb_id: movie.id,
title: movie.title,
poster_path: movie.poster_path,
})
setInWatchlist(true)
}
setSaving(false)
}
async function handleShare() {
const shareData = {
title: movie.title,
text: `Schau dir "${movie.title}" an - ${movie.tagline || movie.overview?.slice(0, 100)}`,
url: window.location.href,
}
if (navigator.share) {
try {
await navigator.share(shareData)
} catch {
// User cancelled
}
} else {
await navigator.clipboard.writeText(window.location.href)
}
}
return (
<main className="mx-auto max-w-lg">
{/* Backdrop */}
<div className="relative h-56 w-full">
{backdrop ? (
<Image
src={backdrop || "/placeholder.svg"}
alt=""
fill
className="object-cover"
sizes="100vw"
priority
/>
) : (
<div className="h-full w-full bg-secondary" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
{/* Back button */}
<button
onClick={() => router.back()}
className="absolute left-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-background/80 text-foreground backdrop-blur-sm"
type="button"
aria-label="Zurueck"
>
<ArrowLeft className="h-5 w-5" />
</button>
{/* Share button */}
<button
onClick={handleShare}
className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-background/80 text-foreground backdrop-blur-sm"
type="button"
aria-label="Teilen"
>
<Share2 className="h-5 w-5" />
</button>
</div>
{/* Movie Info */}
<div className="relative -mt-20 px-4">
<div className="flex gap-4">
{/* Poster */}
<div className="relative h-40 w-[107px] shrink-0 overflow-hidden rounded-lg shadow-lg">
{poster ? (
<Image
src={poster || "/placeholder.svg"}
alt={movie.title}
fill
className="object-cover"
sizes="107px"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-secondary">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
)}
</div>
<div className="flex flex-col justify-end gap-1 pt-20">
<h1 className="font-heading text-xl font-bold leading-tight text-foreground text-balance">
{movie.title}
</h1>
<p className="text-xs text-muted-foreground">
{movie.release_date?.slice(0, 4)}
{movie.runtime ? ` \u00B7 ${movie.runtime} Min.` : ""}
</p>
{movie.genres && (
<div className="mt-1 flex flex-wrap gap-1.5">
{movie.genres.slice(0, 3).map((g) => (
<span
key={g.id}
className="rounded-md bg-secondary px-2 py-0.5 text-[10px] font-medium text-secondary-foreground"
>
{g.name}
</span>
))}
</div>
)}
</div>
</div>
{/* Tagline */}
{movie.tagline && (
<p className="mt-4 text-sm italic text-muted-foreground">
&quot;{movie.tagline}&quot;
</p>
)}
{/* Actions */}
<div className="mt-4 flex gap-3">
<Link
href={`/log?tmdb_id=${movie.id}&title=${encodeURIComponent(movie.title)}&poster_path=${movie.poster_path || ""}`}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-primary py-3 text-sm font-semibold text-primary-foreground"
>
<PenLine className="h-4 w-4" />
Loggen
</Link>
<button
onClick={toggleWatchlist}
disabled={saving}
className={`flex items-center justify-center gap-2 rounded-lg border px-5 py-3 text-sm font-semibold transition-colors ${
inWatchlist
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-foreground hover:bg-secondary"
}`}
type="button"
>
{inWatchlist ? (
<BookmarkCheck className="h-4 w-4" />
) : (
<Bookmark className="h-4 w-4" />
)}
{inWatchlist ? "Gemerkt" : "Merken"}
</button>
</div>
{/* Overview */}
{movie.overview && (
<div className="mt-6">
<h2 className="mb-2 font-heading text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Handlung
</h2>
<p className="text-sm leading-relaxed text-foreground/80">
{movie.overview}
</p>
</div>
)}
{/* Family Reviews */}
{familyEntries.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 font-heading text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Familien-Bewertungen
</h2>
<div className="flex flex-col gap-3">
{familyEntries.map((entry) => (
<div
key={entry.id}
className="rounded-xl border border-border bg-card p-3"
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-[10px] font-bold text-primary">
{entry.profiles?.display_name?.charAt(0).toUpperCase()}
</div>
<span className="text-xs font-medium text-foreground">
{entry.profiles?.display_name}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(entry.watched_at).toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
})}
</span>
</div>
{entry.rating && (
<div className="mt-1.5">
<StarRating rating={entry.rating} size="sm" />
</div>
)}
{entry.review && (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{entry.review}
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="h-8" />
</main>
)
}

View File

@ -0,0 +1,113 @@
"use client"
import { useRouter } from "next/navigation"
import { createClient } from "@/lib/supabase/client"
import { BookOpen, Bookmark, ListIcon, LogOut, Film } from "lucide-react"
interface ProfileContentProps {
profile: { display_name: string; avatar_url: string | null } | null
email: string
stats: {
diary: number
watchlist: number
lists: number
}
}
export function ProfileContent({
profile,
email,
stats,
}: ProfileContentProps) {
const router = useRouter()
async function handleLogout() {
const supabase = createClient()
await supabase.auth.signOut()
router.push("/auth/login")
router.refresh()
}
const initials = profile?.display_name
? profile.display_name.slice(0, 2).toUpperCase()
: "?"
return (
<main className="mx-auto max-w-lg">
<header className="border-b border-border px-4 py-3">
<h1 className="font-heading text-xl font-bold text-foreground">
Profil
</h1>
</header>
<div className="px-4 pt-8">
{/* Avatar & Name */}
<div className="flex flex-col items-center gap-3">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/20">
<span className="font-heading text-2xl font-bold text-primary">
{initials}
</span>
</div>
<div className="text-center">
<h2 className="font-heading text-xl font-bold text-foreground">
{profile?.display_name || "Film-Fan"}
</h2>
<p className="text-sm text-muted-foreground">{email}</p>
</div>
</div>
{/* Stats */}
<div className="mt-8 grid grid-cols-3 gap-3">
<StatCard icon={BookOpen} label="Gesehen" value={stats.diary} />
<StatCard icon={Bookmark} label="Watchlist" value={stats.watchlist} />
<StatCard icon={ListIcon} label="Listen" value={stats.lists} />
</div>
{/* Actions */}
<div className="mt-8 flex flex-col gap-3">
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 rounded-xl border border-border bg-card py-3.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
type="button"
>
<LogOut className="h-4 w-4" />
Abmelden
</button>
</div>
{/* App info */}
<div className="mt-12 flex flex-col items-center gap-1 text-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Film className="h-4 w-4" />
<span className="text-xs font-medium">InFocus</span>
</div>
<p className="text-[10px] text-muted-foreground">
Familienfilm-Tagebuch
</p>
</div>
</div>
<div className="h-8" />
</main>
)
}
function StatCard({
icon: Icon,
label,
value,
}: {
icon: typeof BookOpen
label: string
value: number
}) {
return (
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-card py-4">
<Icon className="h-5 w-5 text-primary" />
<span className="font-heading text-xl font-bold text-foreground">
{value}
</span>
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
)
}

View File

@ -0,0 +1,59 @@
"use client"
import { Star } from "lucide-react"
interface StarRatingProps {
rating: number
maxRating?: number
size?: "sm" | "md" | "lg"
interactive?: boolean
onChange?: (rating: number) => void
}
const sizes = {
sm: "h-3.5 w-3.5",
md: "h-5 w-5",
lg: "h-7 w-7",
}
export function StarRating({
rating,
maxRating = 5,
size = "md",
interactive = false,
onChange,
}: StarRatingProps) {
// Convert 1-10 scale to 1-5 for display
const displayRating = maxRating === 5 ? rating / 2 : rating
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => {
const starValue = i + 1
const filled = displayRating >= starValue
const halfFilled = !filled && displayRating >= starValue - 0.5
return (
<button
key={i}
type="button"
disabled={!interactive}
onClick={() => onChange?.(starValue * 2)}
className={`${interactive ? "cursor-pointer hover:scale-110 transition-transform" : "cursor-default"}`}
aria-label={`${starValue} von 5 Sternen`}
>
<Star
className={`${sizes[size]} ${
filled
? "fill-primary text-primary"
: halfFilled
? "fill-primary/50 text-primary"
: "fill-none text-muted-foreground/40"
}`}
/>
</button>
)
})}
</div>
)
}