From 94f2f8fd0837cb0f4df0ccb1fbf5a841ed4ffca2 Mon Sep 17 00:00:00 2001 From: v0 Date: Sat, 7 Feb 2026 01:12:32 +0000 Subject: [PATCH] feat: build app with infrastructure, auth, and features\n\nAdd dark theme, Supabase, and PWA manifest. --- app/globals.css | 110 +++++++++++++++---------------------- app/layout.tsx | 42 ++++++++++---- lib/supabase/client.ts | 8 +++ lib/supabase/middleware.ts | 62 +++++++++++++++++++++ lib/supabase/server.ts | 29 ++++++++++ lib/tmdb.ts | 64 +++++++++++++++++++++ middleware.ts | 12 ++++ next.config.mjs | 7 +++ package.json | 5 +- public/manifest.json | 22 ++++++++ tailwind.config.ts | 4 ++ 11 files changed, 287 insertions(+), 78 deletions(-) create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/tmdb.ts create mode 100644 middleware.ts create mode 100644 public/manifest.json diff --git a/app/globals.css b/app/globals.css index ac68442..845aae8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,10 +2,6 @@ @tailwind components; @tailwind utilities; -body { - font-family: Arial, Helvetica, sans-serif; -} - @layer utilities { .text-balance { text-wrap: balance; @@ -14,73 +10,31 @@ body { @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; + --background: 220 20% 7%; + --foreground: 210 20% 95%; + --card: 220 18% 10%; + --card-foreground: 210 20% 95%; + --popover: 220 18% 10%; + --popover-foreground: 210 20% 95%; + --primary: 24 100% 55%; + --primary-foreground: 220 20% 7%; + --secondary: 220 15% 16%; + --secondary-foreground: 210 20% 90%; + --muted: 220 15% 14%; + --muted-foreground: 215 15% 55%; + --accent: 24 80% 50%; + --accent-foreground: 220 20% 7%; + --destructive: 0 72% 51%; + --destructive-foreground: 210 20% 95%; + --border: 220 15% 18%; + --input: 220 15% 18%; + --ring: 24 100% 55%; + --chart-1: 24 100% 55%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; - --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --radius: 0.75rem; } } @@ -92,3 +46,25 @@ body { @apply bg-background text-foreground; } } + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; + -webkit-tap-highlight-color: transparent; +} + +/* Hide scrollbar for mobile app feel */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} +.hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Safe area for PWA */ +@supports(padding: max(0px)) { + .safe-bottom { + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index fd10913..8c3207a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,33 @@ -import type { Metadata } from 'next' -import { Geist, Geist_Mono } from 'next/font/google' +import React from "react" +import type { Metadata, Viewport } from "next" +import { Inter, Space_Grotesk } from "next/font/google" -import './globals.css' +import "./globals.css" -const _geist = Geist({ subsets: ['latin'] }) -const _geistMono = Geist_Mono({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }) +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-space-grotesk", +}) export const metadata: Metadata = { - title: 'v0 App', - description: 'Created with v0', - generator: 'v0.app', + title: "InFocus - Familienfilm-Tagebuch", + description: + "Teile deine Filmerlebnisse mit deiner Familie. Bewerte, logge und entdecke Filme zusammen.", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "InFocus", + }, +} + +export const viewport: Viewport = { + themeColor: "#111318", + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, } export default function RootLayout({ @@ -18,8 +36,12 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - {children} + + + {children} + ) } diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts new file mode 100644 index 0000000..c48a435 --- /dev/null +++ b/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ) +} diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts new file mode 100644 index 0000000..874d983 --- /dev/null +++ b/lib/supabase/middleware.ts @@ -0,0 +1,62 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ) + }, + }, + }, + ) + + const { + data: { user }, + } = await supabase.auth.getUser() + + // Redirect unauthenticated users trying to access the app + const isAuthRoute = request.nextUrl.pathname.startsWith('/auth') + const isApiRoute = request.nextUrl.pathname.startsWith('/api') + const isPublicRoute = request.nextUrl.pathname === '/' + + if (!user && !isAuthRoute && !isApiRoute && !isPublicRoute) { + const url = request.nextUrl.clone() + url.pathname = '/auth/login' + return NextResponse.redirect(url) + } + + // Redirect authenticated users away from auth pages to the feed + if (user && isAuthRoute) { + const url = request.nextUrl.clone() + url.pathname = '/feed' + return NextResponse.redirect(url) + } + + // Redirect root to feed if authenticated + if (user && isPublicRoute) { + const url = request.nextUrl.clone() + url.pathname = '/feed' + return NextResponse.redirect(url) + } + + return supabaseResponse +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts new file mode 100644 index 0000000..e60931a --- /dev/null +++ b/lib/supabase/server.ts @@ -0,0 +1,29 @@ +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' + +export async function createClient() { + const cookieStore = await cookies() + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ) + } catch { + // The "setAll" method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }, + ) +} diff --git a/lib/tmdb.ts b/lib/tmdb.ts new file mode 100644 index 0000000..25c41c2 --- /dev/null +++ b/lib/tmdb.ts @@ -0,0 +1,64 @@ +const TMDB_BASE = 'https://api.themoviedb.org/3' +const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p' + +export function posterUrl(path: string | null, size: 'w185' | 'w342' | 'w500' | 'original' = 'w342') { + if (!path) return null + return `${TMDB_IMAGE_BASE}/${size}${path}` +} + +export function backdropUrl(path: string | null, size: 'w780' | 'w1280' | 'original' = 'w1280') { + if (!path) return null + return `${TMDB_IMAGE_BASE}/${size}${path}` +} + +async function tmdbFetch(endpoint: string, params: Record = {}) { + const url = new URL(`${TMDB_BASE}${endpoint}`) + url.searchParams.set('api_key', process.env.TMDB_API_KEY!) + url.searchParams.set('language', 'de-DE') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + const res = await fetch(url.toString(), { next: { revalidate: 3600 } }) + if (!res.ok) throw new Error(`TMDB error: ${res.status}`) + return res.json() +} + +export async function searchMovies(query: string, page = 1) { + return tmdbFetch('/search/movie', { query, page: String(page) }) +} + +export async function getMovie(id: number) { + return tmdbFetch(`/movie/${id}`) +} + +export async function getTrending() { + return tmdbFetch('/trending/movie/week') +} + +export async function getPopular(page = 1) { + return tmdbFetch('/movie/popular', { page: String(page) }) +} + +export interface TMDBMovie { + id: number + title: string + poster_path: string | null + backdrop_path: string | null + overview: string + release_date: string + vote_average: number + genre_ids?: number[] +} + +export interface TMDBSearchResult { + page: number + results: TMDBMovie[] + total_pages: number + total_results: number +} + +export interface TMDBMovieDetail extends TMDBMovie { + runtime: number + genres: { id: number; name: string }[] + tagline: string +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..1c4e535 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,12 @@ +import { updateSession } from '@/lib/supabase/middleware' +import { type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + return await updateSession(request) +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|manifest.json|icons/.*|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +} diff --git a/next.config.mjs b/next.config.mjs index 4cd9948..8d41b18 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,6 +5,13 @@ const nextConfig = { }, images: { unoptimized: true, + remotePatterns: [ + { + protocol: "https", + hostname: "image.tmdb.org", + pathname: "/t/p/**", + }, + ], }, } diff --git a/package.json b/package.json index 33f94f3..f71293e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint ." }, "dependencies": { + "@supabase/ssr": "^0.5.2", + "@supabase/supabase-js": "^2.47.12", + "swr": "^2.2.5", "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", @@ -68,4 +71,4 @@ "tailwindcss": "^3.4.17", "typescript": "5.7.3" } -} \ No newline at end of file +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..fcdc82b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "InFocus - Familienfilm-Tagebuch", + "short_name": "InFocus", + "description": "Teile deine Filmerlebnisse mit deiner Familie", + "start_url": "/feed", + "display": "standalone", + "background_color": "#111318", + "theme_color": "#111318", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/tailwind.config.ts b/tailwind.config.ts index f9ffe6b..b6c9361 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,6 +10,10 @@ const config: Config = { ], theme: { extend: { + fontFamily: { + sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], + heading: ['var(--font-space-grotesk)', 'system-ui', 'sans-serif'], + }, colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))',