feat: build app with infrastructure, auth, and features\n\nAdd dark theme, Supabase, and PWA manifest.

This commit is contained in:
v0 2026-02-07 01:12:32 +00:00
parent 5e9f299bab
commit 94f2f8fd08
11 changed files with 287 additions and 78 deletions

View File

@ -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));
}
}

View File

@ -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 (
<html lang="en">
<body className="font-sans antialiased">{children}</body>
<html lang="de">
<body
className={`${inter.variable} ${spaceGrotesk.variable} font-sans antialiased`}
>
{children}
</body>
</html>
)
}

8
lib/supabase/client.ts Normal file
View File

@ -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!,
)
}

View File

@ -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
}

29
lib/supabase/server.ts Normal file
View File

@ -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.
}
},
},
},
)
}

64
lib/tmdb.ts Normal file
View File

@ -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<string, string> = {}) {
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
}

12
middleware.ts Normal file
View File

@ -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)$).*)',
],
}

View File

@ -5,6 +5,13 @@ const nextConfig = {
},
images: {
unoptimized: true,
remotePatterns: [
{
protocol: "https",
hostname: "image.tmdb.org",
pathname: "/t/p/**",
},
],
},
}

View File

@ -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"
}
}
}

22
public/manifest.json Normal file
View File

@ -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"
}
]
}

View File

@ -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))',