feat: build app with infrastructure, auth, and features\n\nAdd dark theme, Supabase, and PWA manifest.
This commit is contained in:
parent
5e9f299bab
commit
94f2f8fd08
110
app/globals.css
110
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
8
lib/supabase/client.ts
Normal 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!,
|
||||
)
|
||||
}
|
||||
62
lib/supabase/middleware.ts
Normal file
62
lib/supabase/middleware.ts
Normal 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
29
lib/supabase/server.ts
Normal 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
64
lib/tmdb.ts
Normal 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
12
middleware.ts
Normal 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)$).*)',
|
||||
],
|
||||
}
|
||||
@ -5,6 +5,13 @@ const nextConfig = {
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "image.tmdb.org",
|
||||
pathname: "/t/p/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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
22
public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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))',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user