build complete app with Supabase, auth, and feature pages
This commit is contained in:
parent
94f2f8fd08
commit
dab373a1d9
35
app/(app)/diary/page.tsx
Normal file
35
app/(app)/diary/page.tsx
Normal 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
38
app/(app)/feed/page.tsx
Normal 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
11
app/(app)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
app/(app)/lists/[id]/page.tsx
Normal file
28
app/(app)/lists/[id]/page.tsx
Normal 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 || []} />
|
||||
}
|
||||
3
app/(app)/log/loading.tsx
Normal file
3
app/(app)/log/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
304
app/(app)/log/page.tsx
Normal file
304
app/(app)/log/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
app/(app)/movie/[id]/page.tsx
Normal file
40
app/(app)/movie/[id]/page.tsx
Normal 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 || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
42
app/(app)/profile/page.tsx
Normal file
42
app/(app)/profile/page.tsx
Normal 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
132
app/(app)/search/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
app/api/tmdb/movie/[id]/route.ts
Normal file
15
app/api/tmdb/movie/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
18
app/api/tmdb/search/route.ts
Normal file
18
app/api/tmdb/search/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
11
app/api/tmdb/trending/route.ts
Normal file
11
app/api/tmdb/trending/route.ts
Normal 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
29
app/auth/error/page.tsx
Normal 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
114
app/auth/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
app/auth/sign-up-success/page.tsx
Normal file
30
app/auth/sign-up-success/page.tsx
Normal 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ä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
138
app/auth/sign-up/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
app/page.tsx
16
app/page.tsx
@ -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
50
components/bottom-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
368
components/diary-content.tsx
Normal file
368
components/diary-content.tsx
Normal 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's
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
214
components/feed-content.tsx
Normal file
214
components/feed-content.tsx
Normal 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ä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ä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>
|
||||
)
|
||||
}
|
||||
233
components/list-detail-content.tsx
Normal file
233
components/list-detail-content.tsx
Normal 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
60
components/movie-card.tsx
Normal 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
265
components/movie-detail.tsx
Normal 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">
|
||||
"{movie.tagline}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
113
components/profile-content.tsx
Normal file
113
components/profile-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
components/star-rating.tsx
Normal file
59
components/star-rating.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user