diff --git a/app/(app)/diary/page.tsx b/app/(app)/diary/page.tsx
new file mode 100644
index 0000000..8ed5e68
--- /dev/null
+++ b/app/(app)/diary/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/app/(app)/feed/page.tsx b/app/(app)/feed/page.tsx
new file mode 100644
index 0000000..495435b
--- /dev/null
+++ b/app/(app)/feed/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
new file mode 100644
index 0000000..45774e4
--- /dev/null
+++ b/app/(app)/layout.tsx
@@ -0,0 +1,11 @@
+import React from "react"
+import { BottomNav } from "@/components/bottom-nav"
+
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/app/(app)/lists/[id]/page.tsx b/app/(app)/lists/[id]/page.tsx
new file mode 100644
index 0000000..ac11c4a
--- /dev/null
+++ b/app/(app)/lists/[id]/page.tsx
@@ -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
+}
diff --git a/app/(app)/log/loading.tsx b/app/(app)/log/loading.tsx
new file mode 100644
index 0000000..f15322a
--- /dev/null
+++ b/app/(app)/log/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return null
+}
diff --git a/app/(app)/log/page.tsx b/app/(app)/log/page.tsx
new file mode 100644
index 0000000..816272b
--- /dev/null
+++ b/app/(app)/log/page.tsx
@@ -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([])
+ 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 (
+
+
+
+
+ {searching && (
+
+
+
+ )}
+ {!searching && results.length === 0 && query.trim() && (
+
+ Keine Filme gefunden
+
+ )}
+
+ {results.map((movie) => {
+ const url = posterUrl(movie.poster_path, "w185")
+ return (
+
+ )
+ })}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Bewerten
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function LogPage() {
+ return (
+ }>
+
+
+ )
+}
diff --git a/app/(app)/movie/[id]/page.tsx b/app/(app)/movie/[id]/page.tsx
new file mode 100644
index 0000000..6d84b8a
--- /dev/null
+++ b/app/(app)/movie/[id]/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/app/(app)/profile/page.tsx b/app/(app)/profile/page.tsx
new file mode 100644
index 0000000..8dff1ae
--- /dev/null
+++ b/app/(app)/profile/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/app/(app)/search/page.tsx b/app/(app)/search/page.tsx
new file mode 100644
index 0000000..30bb926
--- /dev/null
+++ b/app/(app)/search/page.tsx
@@ -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([])
+ const [trending, setTrending] = useState([])
+ 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 (
+
+
+
+
+ {!query.trim() && trending.length > 0 && (
+
+ Beliebt gerade
+
+ )}
+ {query.trim() && results.length === 0 && !loading && (
+
+
+
+ Keine Filme gefunden
+
+
+ )}
+
+
+ {moviesToShow.map((movie) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+function SearchResultCard({ movie }: { movie: TMDBMovie }) {
+ const url = posterUrl(movie.poster_path, "w185")
+
+ return (
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {movie.title}
+
+ {movie.release_date && (
+
+ {movie.release_date.slice(0, 4)}
+
+ )}
+ {movie.overview && (
+
+ {movie.overview}
+
+ )}
+
+
+ )
+}
diff --git a/app/api/tmdb/movie/[id]/route.ts b/app/api/tmdb/movie/[id]/route.ts
new file mode 100644
index 0000000..cc3cea4
--- /dev/null
+++ b/app/api/tmdb/movie/[id]/route.ts
@@ -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 })
+ }
+}
diff --git a/app/api/tmdb/search/route.ts b/app/api/tmdb/search/route.ts
new file mode 100644
index 0000000..aefc1fc
--- /dev/null
+++ b/app/api/tmdb/search/route.ts
@@ -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 })
+ }
+}
diff --git a/app/api/tmdb/trending/route.ts b/app/api/tmdb/trending/route.ts
new file mode 100644
index 0000000..9bfbe69
--- /dev/null
+++ b/app/api/tmdb/trending/route.ts
@@ -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 })
+ }
+}
diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx
new file mode 100644
index 0000000..a071982
--- /dev/null
+++ b/app/auth/error/page.tsx
@@ -0,0 +1,29 @@
+import Link from "next/link"
+import { AlertTriangle, ArrowLeft } from "lucide-react"
+
+export default function AuthErrorPage() {
+ return (
+
+
+
+
+ Fehler bei der Anmeldung
+
+
+ Etwas ist schiefgelaufen. Bitte versuche es erneut.
+
+
+
+ Zur Anmeldeseite
+
+
+
+ )
+}
diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx
new file mode 100644
index 0000000..779d9d5
--- /dev/null
+++ b/app/auth/login/page.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ InFocus
+
+
+ Familienfilm-Tagebuch
+
+
+
+
+
+
+ Noch kein Konto?{" "}
+
+ Registrieren
+
+
+
+
+ )
+}
diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx
new file mode 100644
index 0000000..f34f59c
--- /dev/null
+++ b/app/auth/sign-up-success/page.tsx
@@ -0,0 +1,30 @@
+import Link from "next/link"
+import { Mail, ArrowLeft } from "lucide-react"
+
+export default function SignUpSuccessPage() {
+ return (
+
+
+
+
+ E-Mail pruefen
+
+
+ Wir haben dir eine Bestätigungs-E-Mail geschickt. Klicke auf den Link in
+ der E-Mail, um dein Konto zu aktivieren.
+
+
+
+ Zur Anmeldeseite
+
+
+
+ )
+}
diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx
new file mode 100644
index 0000000..a96c6fc
--- /dev/null
+++ b/app/auth/sign-up/page.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ Konto erstellen
+
+
+ Tritt deinem Familien-Filmclub bei
+
+
+
+
+
+
+ Bereits ein Konto?{" "}
+
+ Anmelden
+
+
+
+
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
index c174313..fb00506 100644
--- a/app/page.tsx
+++ b/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")
+ }
}
diff --git a/components/bottom-nav.tsx b/components/bottom-nav.tsx
new file mode 100644
index 0000000..2cb40f2
--- /dev/null
+++ b/components/bottom-nav.tsx
@@ -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 (
+
+ )
+}
diff --git a/components/diary-content.tsx b/components/diary-content.tsx
new file mode 100644
index 0000000..11fb3b2
--- /dev/null
+++ b/components/diary-content.tsx
@@ -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("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 (
+
+
+
+ Meine Filme
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon
+ return (
+
+ )
+ })}
+
+
+
+
+ {/* Diary Tab */}
+ {activeTab === "diary" && (
+ <>
+ {entries.length === 0 ? (
+
+ ) : (
+
+ {entries.map((entry) => {
+ const url = posterUrl(entry.poster_path, "w185")
+ return (
+
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {entry.title}
+
+
+
+ {new Date(entry.watched_at).toLocaleDateString(
+ "de-DE",
+ { day: "numeric", month: "long", year: "numeric" }
+ )}
+
+ {entry.rating && (
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+ >
+ )}
+
+ {/* Watchlist Tab */}
+ {activeTab === "watchlist" && (
+ <>
+ {watchlist.length === 0 ? (
+
+ ) : (
+
+ {watchlist.map((item) => {
+ const url = posterUrl(item.poster_path, "w185")
+ return (
+
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {item.title}
+
+
+
+
+ )
+ })}
+
+ )}
+ >
+ )}
+
+ {/* Lists Tab */}
+ {activeTab === "lists" && (
+ <>
+
+
+ {showNewList && (
+
+ )}
+
+ {lists.length === 0 && !showNewList ? (
+
+ ) : (
+
+ {lists.map((list) => (
+
+
+
+ {list.name}
+
+ {list.description && (
+
+ {list.description}
+
+ )}
+
+
+ {list.list_items?.[0]?.count || 0} Filme
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+
+ )
+}
+
+function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ href,
+}: {
+ icon: typeof BookOpen
+ title: string
+ description: string
+ href?: string
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+ {href && (
+
+ Los geht's
+
+ )}
+
+ )
+}
diff --git a/components/feed-content.tsx b/components/feed-content.tsx
new file mode 100644
index 0000000..12c6a46
--- /dev/null
+++ b/components/feed-content.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+ {/* Trending Section */}
+ {trending.length > 0 && (
+
+
+ Trending diese Woche
+
+
+ {trending.map((movie) => (
+
+ ))}
+
+
+ )}
+
+ {/* Family Feed */}
+
+
+ Familien-Aktivität
+
+ {entries.length === 0 ? (
+
+
+
+ Noch keine Einträge. Logge deinen ersten Film!
+
+
+ Film loggen
+
+
+ ) : (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
+
+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 (
+
+
+ {/* Poster */}
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Content */}
+
+
+
+ {entry.profiles?.display_name?.charAt(0).toUpperCase() || "?"}
+
+
+ {entry.profiles?.display_name}
+
+
+ {watchedDate}
+
+
+
+
+
+ {entry.title}
+
+
+
+ {entry.rating &&
}
+
+ {entry.review && (
+
+ {entry.review}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
+
+
+ )
+}
diff --git a/components/list-detail-content.tsx b/components/list-detail-content.tsx
new file mode 100644
index 0000000..018c544
--- /dev/null
+++ b/components/list-detail-content.tsx
@@ -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([])
+ 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 (
+
+
+
+
+ {list.name}
+
+
+
+
+
+ {/* Add movie */}
+ {showSearch ? (
+
+
+
+ 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
+ />
+
+
+ {searching && (
+
+
+
+ )}
+ {searchResults.map((movie) => {
+ const url = posterUrl(movie.poster_path, "w185")
+ const alreadyAdded = items.some((i) => i.tmdb_id === movie.id)
+ return (
+
+ )
+ })}
+
+ ) : (
+
+ )}
+
+ {/* List items */}
+ {items.length === 0 ? (
+
+
+
+ Diese Liste ist noch leer
+
+
+ ) : (
+
+ {items.map((item) => {
+ const url = posterUrl(item.poster_path, "w185")
+ return (
+
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {item.title}
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/components/movie-card.tsx b/components/movie-card.tsx
new file mode 100644
index 0000000..920399f
--- /dev/null
+++ b/components/movie-card.tsx
@@ -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 (
+
+
+ {url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
{title}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ )
+}
diff --git a/components/movie-detail.tsx b/components/movie-detail.tsx
new file mode 100644
index 0000000..fa2c09b
--- /dev/null
+++ b/components/movie-detail.tsx
@@ -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 (
+
+ {/* Backdrop */}
+
+ {backdrop ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Back button */}
+
+
+ {/* Share button */}
+
+
+
+ {/* Movie Info */}
+
+
+ {/* Poster */}
+
+ {poster ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {movie.title}
+
+
+ {movie.release_date?.slice(0, 4)}
+ {movie.runtime ? ` \u00B7 ${movie.runtime} Min.` : ""}
+
+ {movie.genres && (
+
+ {movie.genres.slice(0, 3).map((g) => (
+
+ {g.name}
+
+ ))}
+
+ )}
+
+
+
+ {/* Tagline */}
+ {movie.tagline && (
+
+ "{movie.tagline}"
+
+ )}
+
+ {/* Actions */}
+
+
+
+ Loggen
+
+
+
+
+ {/* Overview */}
+ {movie.overview && (
+
+
+ Handlung
+
+
+ {movie.overview}
+
+
+ )}
+
+ {/* Family Reviews */}
+ {familyEntries.length > 0 && (
+
+
+ Familien-Bewertungen
+
+
+ {familyEntries.map((entry) => (
+
+
+
+ {entry.profiles?.display_name?.charAt(0).toUpperCase()}
+
+
+ {entry.profiles?.display_name}
+
+
+ {new Date(entry.watched_at).toLocaleDateString("de-DE", {
+ day: "numeric",
+ month: "short",
+ })}
+
+
+ {entry.rating && (
+
+
+
+ )}
+ {entry.review && (
+
+ {entry.review}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/components/profile-content.tsx b/components/profile-content.tsx
new file mode 100644
index 0000000..bb61b5a
--- /dev/null
+++ b/components/profile-content.tsx
@@ -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 (
+
+
+
+
+ {/* Avatar & Name */}
+
+
+
+ {initials}
+
+
+
+
+ {profile?.display_name || "Film-Fan"}
+
+
{email}
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+ {/* App info */}
+
+
+
+ InFocus
+
+
+ Familienfilm-Tagebuch
+
+
+
+
+
+
+ )
+}
+
+function StatCard({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: typeof BookOpen
+ label: string
+ value: number
+}) {
+ return (
+
+
+
+ {value}
+
+ {label}
+
+ )
+}
diff --git a/components/star-rating.tsx b/components/star-rating.tsx
new file mode 100644
index 0000000..8c5d208
--- /dev/null
+++ b/components/star-rating.tsx
@@ -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 (
+
+ {Array.from({ length: 5 }, (_, i) => {
+ const starValue = i + 1
+ const filled = displayRating >= starValue
+ const halfFilled = !filled && displayRating >= starValue - 0.5
+
+ return (
+
+ )
+ })}
+
+ )
+}