Add light/dark mode with toggle, fix all theme-aware text colors
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"
|
||||
import L from "leaflet"
|
||||
import "leaflet/dist/leaflet.css"
|
||||
@@ -14,7 +14,7 @@ function createAmberIcon(isSelected: boolean) {
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="14" r="10" fill="${isSelected ? "#f59e0b" : "#d97706"}" stroke="${isSelected ? "#fff" : "#f59e0b"}" stroke-width="2"/>
|
||||
<path d="M16 28 L10 20 Q16 22 22 20 Z" fill="${isSelected ? "#f59e0b" : "#d97706"}"/>
|
||||
<circle cx="16" cy="14" r="4" fill="${isSelected ? "#000" : "#0d0d0d"}"/>
|
||||
<circle cx="16" cy="14" r="4" fill="${isSelected ? "#000" : "#333"}"/>
|
||||
</svg>
|
||||
`
|
||||
return L.divIcon({
|
||||
@@ -53,24 +53,30 @@ interface Props {
|
||||
restaurants: Restaurant[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
theme?: "light" | "dark"
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, selectedId, onSelect }: Props) {
|
||||
export default function MapView({ restaurants, selectedId, onSelect, theme = "dark" }: Props) {
|
||||
const center: [number, number] =
|
||||
restaurants.length > 0
|
||||
? [restaurants[0].lat, restaurants[0].lng]
|
||||
: [51.5, 10]
|
||||
|
||||
const tileUrl = theme === "dark"
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={5}
|
||||
style={{ height: "100%", width: "100%", background: "#0d0d0d" }}
|
||||
style={{ height: "100%", width: "100%", background: theme === "dark" ? "#0d0d0d" : "#f0f0f0" }}
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
key={tileUrl}
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
url={tileUrl}
|
||||
subdomains="abcd"
|
||||
maxZoom={20}
|
||||
/>
|
||||
@@ -88,8 +94,8 @@ export default function MapView({ restaurants, selectedId, onSelect }: Props) {
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[180px]">
|
||||
<p className="font-bold text-[#f5f5f5] text-base leading-tight">{r.name}</p>
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
<p className="font-bold text-base leading-tight">{r.name}</p>
|
||||
<p className="text-xs mt-1 opacity-50">
|
||||
{r.city}, {r.country}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
@@ -106,7 +112,7 @@ export default function MapView({ restaurants, selectedId, onSelect }: Props) {
|
||||
<span className="text-[#f59e0b] font-black text-lg">
|
||||
{avgRating(r).toFixed(1)}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">
|
||||
<span className="text-xs opacity-40">
|
||||
{r.visits.length} {r.visits.length === 1 ? "visit" : "visits"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,46 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
import { useTheme } from "@/app/components/ThemeProvider"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Home", icon: "◈" },
|
||||
{ href: "/map", label: "Map", icon: "◉" },
|
||||
]
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Nav() {
|
||||
const pathname = usePathname()
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop top nav */}
|
||||
<nav className="hidden md:flex items-center justify-between px-8 py-5 border-b border-white/5 sticky top-0 z-50 bg-[#0d0d0d]/80 backdrop-blur-xl">
|
||||
<Link href="/" className="text-2xl font-black tracking-tighter text-[#f5f5f5] hover:text-[#f59e0b] transition-colors">
|
||||
<nav className="hidden md:flex items-center justify-between px-8 py-5 border-b border-black/[0.06] dark:border-white/[0.05] sticky top-0 z-50 bg-[#fafafa]/80 dark:bg-[#0d0d0d]/80 backdrop-blur-xl">
|
||||
<Link href="/" className="text-2xl font-black tracking-tighter text-[#111] dark:text-[#f5f5f5] hover:text-[#f59e0b] dark:hover:text-[#f59e0b] transition-colors">
|
||||
food.
|
||||
</Link>
|
||||
<div className="flex items-center gap-8">
|
||||
@@ -25,17 +51,26 @@ export default function Nav() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`text-sm font-medium transition-colors hover:text-[#f59e0b] ${
|
||||
pathname === item.href ? "text-[#f59e0b]" : "text-white/60"
|
||||
pathname === item.href
|
||||
? "text-[#f59e0b]"
|
||||
: "text-black/50 dark:text-white/60"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Toggle theme"
|
||||
className="text-black/40 dark:text-white/40 hover:text-[#f59e0b] dark:hover:text-[#f59e0b] transition-colors p-1"
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-white/5 bg-[#0d0d0d]/90 backdrop-blur-xl">
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-black/[0.06] dark:border-white/[0.05] bg-[#fafafa]/90 dark:bg-[#0d0d0d]/90 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-around py-3">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
@@ -48,14 +83,14 @@ export default function Nav() {
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.85 }}
|
||||
className={`text-xl transition-colors ${
|
||||
isActive ? "text-[#f59e0b]" : "text-white/40"
|
||||
isActive ? "text-[#f59e0b]" : "text-black/30 dark:text-white/40"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</motion.div>
|
||||
<span
|
||||
className={`text-[10px] font-medium tracking-wider uppercase transition-colors ${
|
||||
isActive ? "text-[#f59e0b]" : "text-white/30"
|
||||
isActive ? "text-[#f59e0b]" : "text-black/25 dark:text-white/30"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
@@ -63,6 +98,22 @@ export default function Nav() {
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* Theme toggle in mobile nav */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Toggle theme"
|
||||
className="flex flex-col items-center gap-1 px-6 py-1"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.85 }}
|
||||
className="text-xl text-black/30 dark:text-white/40 hover:text-[#f59e0b] transition-colors"
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</motion.div>
|
||||
<span className="text-[10px] font-medium tracking-wider uppercase text-black/25 dark:text-white/30">
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -30,18 +30,19 @@ export default function RestaurantCard({ restaurant, latestVisit, index }: Props
|
||||
transition={{ duration: 0.4, delay: index * 0.08, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<Link href={`/restaurant/${restaurant.id}`}>
|
||||
<div className="group relative rounded-2xl border border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/[0.12] transition-all duration-300 p-5 cursor-pointer overflow-hidden">
|
||||
<div className="group relative rounded-2xl border border-black/[0.07] dark:border-white/[0.06] bg-white dark:bg-white/[0.03] hover:bg-stone-50 dark:hover:bg-white/[0.06] hover:border-black/[0.12] dark:hover:border-white/[0.12] transition-all duration-300 p-5 cursor-pointer overflow-hidden shadow-sm dark:shadow-none">
|
||||
{/* Amber glow on hover */}
|
||||
<div className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
|
||||
style={{ background: "radial-gradient(circle at 50% 0%, rgba(245,158,11,0.06) 0%, transparent 70%)" }}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-[#f5f5f5] group-hover:text-[#f59e0b] transition-colors truncate">
|
||||
<h3 className="text-lg font-bold text-[#111] dark:text-[#f5f5f5] group-hover:text-[#f59e0b] transition-colors truncate">
|
||||
{restaurant.name}
|
||||
</h3>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-0.5">
|
||||
{restaurant.city}, {restaurant.country}
|
||||
</p>
|
||||
</div>
|
||||
@@ -51,9 +52,9 @@ export default function RestaurantCard({ restaurant, latestVisit, index }: Props
|
||||
<span className="text-[#f59e0b] font-black text-lg leading-none">
|
||||
{latestVisit.rating}
|
||||
</span>
|
||||
<span className="text-white/30 text-xs">/10</span>
|
||||
<span className="text-black/25 dark:text-white/30 text-xs">/10</span>
|
||||
</div>
|
||||
<span className="text-white/30 text-xs font-medium tracking-wide">
|
||||
<span className="text-black/30 dark:text-white/30 text-xs font-medium tracking-wide">
|
||||
{priceLabel(restaurant.price_range)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -61,7 +62,7 @@ export default function RestaurantCard({ restaurant, latestVisit, index }: Props
|
||||
|
||||
<div className="mt-4">
|
||||
{latestVisit.dishes[0] && (
|
||||
<p className="text-sm text-white/60 italic truncate">
|
||||
<p className="text-sm text-black/50 dark:text-white/60 italic truncate">
|
||||
“{latestVisit.dishes[0]}”
|
||||
</p>
|
||||
)}
|
||||
@@ -72,20 +73,20 @@ export default function RestaurantCard({ restaurant, latestVisit, index }: Props
|
||||
{restaurant.cuisine.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-[#f59e0b]/70 bg-[#f59e0b]/10 rounded-full px-2.5 py-0.5"
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-[#f59e0b] bg-[#f59e0b]/10 rounded-full px-2.5 py-0.5"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-white/25 shrink-0">
|
||||
<span className="text-xs text-black/25 dark:text-white/25 shrink-0">
|
||||
{formatDate(latestVisit.date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{restaurant.visits.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-white/[0.05]">
|
||||
<p className="text-xs text-white/30">
|
||||
<div className="mt-3 pt-3 border-t border-black/[0.05] dark:border-white/[0.05]">
|
||||
<p className="text-xs text-black/30 dark:text-white/30">
|
||||
{restaurant.visits.length} visits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
55
app/components/ThemeProvider.tsx
Normal file
55
app/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "light" | "dark"
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "dark",
|
||||
toggle: () => {},
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
|
||||
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark")
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Read from localStorage, fall back to system preference
|
||||
const stored = localStorage.getItem("food-theme") as Theme | null
|
||||
if (stored === "light" || stored === "dark") {
|
||||
setTheme(stored)
|
||||
} else {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
setTheme(prefersDark ? "dark" : "light")
|
||||
}
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const root = document.documentElement
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark")
|
||||
} else {
|
||||
root.classList.remove("dark")
|
||||
}
|
||||
localStorage.setItem("food-theme", theme)
|
||||
}, [theme, mounted])
|
||||
|
||||
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"))
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0d0d0d;
|
||||
--foreground: #f5f5f5;
|
||||
--accent: #f59e0b;
|
||||
--accent-dim: #d97706;
|
||||
--surface: rgba(255, 255, 255, 0.04);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
/* Dark mode via .dark class on <html> */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
@@ -25,8 +17,6 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0d0d0d;
|
||||
color: #f5f5f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -38,7 +28,7 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0d0d0d;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@@ -50,23 +40,35 @@ body {
|
||||
background: rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
|
||||
/* Leaflet dark overrides */
|
||||
.leaflet-container {
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
/* Leaflet popup — light mode */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: #1a1a1a;
|
||||
color: #f5f5f5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: #1a1a1a;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.leaflet-popup-close-button {
|
||||
color: #111111 !important;
|
||||
}
|
||||
|
||||
/* Leaflet popup — dark mode */
|
||||
.dark .leaflet-popup-content-wrapper {
|
||||
background: #1a1a1a;
|
||||
color: #f5f5f5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.dark .leaflet-popup-tip {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.dark .leaflet-popup-close-button {
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Nav from "@/app/components/Nav";
|
||||
import ThemeProvider from "@/app/components/ThemeProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -28,11 +29,13 @@ export default function RootLayout({
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-[#0d0d0d] text-[#f5f5f5]">
|
||||
<Nav />
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
<body className="min-h-full flex flex-col bg-[#fafafa] dark:bg-[#0d0d0d] text-[#111111] dark:text-[#f5f5f5] transition-colors duration-200">
|
||||
<ThemeProvider>
|
||||
<Nav />
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
import { Restaurant } from "@/app/types"
|
||||
import { useTheme } from "@/app/components/ThemeProvider"
|
||||
|
||||
const MapView = dynamic(() => import("@/app/components/MapView"), { ssr: false })
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function MapPage() {
|
||||
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/restaurants")
|
||||
@@ -35,7 +37,7 @@ export default function MapPage() {
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full bg-[#0d0d0d]">
|
||||
<div className="flex items-center justify-center h-full bg-[#fafafa] dark:bg-[#0d0d0d]">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
@@ -47,14 +49,15 @@ export default function MapPage() {
|
||||
restaurants={restaurants}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-80 flex-col border-l border-white/[0.06] bg-[#0d0d0d] overflow-y-auto">
|
||||
<div className="p-5 border-b border-white/[0.06]">
|
||||
<h2 className="text-lg font-black tracking-tight">
|
||||
<aside className="hidden md:flex w-80 flex-col border-l border-black/[0.06] dark:border-white/[0.06] bg-[#fafafa] dark:bg-[#0d0d0d] overflow-y-auto">
|
||||
<div className="p-5 border-b border-black/[0.06] dark:border-white/[0.06]">
|
||||
<h2 className="text-lg font-black tracking-tight text-[#111] dark:text-[#f5f5f5]">
|
||||
{restaurants.length} places
|
||||
</h2>
|
||||
</div>
|
||||
@@ -66,18 +69,18 @@ export default function MapPage() {
|
||||
className={`w-full text-left rounded-xl p-3 transition-all border ${
|
||||
selected === r.id
|
||||
? "bg-[#f59e0b]/10 border-[#f59e0b]/30"
|
||||
: "bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.05]"
|
||||
: "bg-black/[0.02] dark:bg-white/[0.02] border-black/[0.06] dark:border-white/[0.06] hover:bg-black/[0.04] dark:hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold text-sm text-[#f5f5f5]">{r.name}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">
|
||||
<p className="font-semibold text-sm text-[#111] dark:text-[#f5f5f5]">{r.name}</p>
|
||||
<p className="text-xs text-black/40 dark:text-white/40 mt-0.5">
|
||||
{r.city} · {r.cuisine[0]}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[#f59e0b] text-xs font-bold">
|
||||
{avgRating(r).toFixed(1)}
|
||||
</span>
|
||||
<span className="text-white/25 text-xs">
|
||||
<span className="text-black/25 dark:text-white/25 text-xs">
|
||||
{r.visits.length} {r.visits.length === 1 ? "visit" : "visits"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -88,7 +91,7 @@ export default function MapPage() {
|
||||
|
||||
{/* Mobile bottom sheet */}
|
||||
<motion.div
|
||||
className="md:hidden fixed bottom-16 left-0 right-0 bg-[#111]/95 backdrop-blur-xl border-t border-white/[0.06] max-h-64 overflow-y-auto z-40"
|
||||
className="md:hidden fixed bottom-16 left-0 right-0 bg-white/95 dark:bg-[#111]/95 backdrop-blur-xl border-t border-black/[0.06] dark:border-white/[0.06] max-h-64 overflow-y-auto z-40"
|
||||
initial={{ y: 200 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
@@ -101,11 +104,11 @@ export default function MapPage() {
|
||||
className={`text-left rounded-xl p-3 transition-all border ${
|
||||
selected === r.id
|
||||
? "bg-[#f59e0b]/10 border-[#f59e0b]/30"
|
||||
: "bg-white/[0.03] border-white/[0.06]"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] border-black/[0.06] dark:border-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold text-xs text-[#f5f5f5] truncate">{r.name}</p>
|
||||
<p className="text-[10px] text-white/40">{r.city}</p>
|
||||
<p className="font-semibold text-xs text-[#111] dark:text-[#f5f5f5] truncate">{r.name}</p>
|
||||
<p className="text-[10px] text-black/40 dark:text-white/40">{r.city}</p>
|
||||
<span className="text-[#f59e0b] text-xs font-bold">
|
||||
{avgRating(r).toFixed(1)}
|
||||
</span>
|
||||
|
||||
12
app/page.tsx
12
app/page.tsx
@@ -99,10 +99,10 @@ export default function HomePage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<h1 className="text-[clamp(4rem,16vw,10rem)] font-black leading-none tracking-tighter text-[#f5f5f5]">
|
||||
<h1 className="text-[clamp(4rem,16vw,10rem)] font-black leading-none tracking-tighter text-[#111] dark:text-[#f5f5f5]">
|
||||
food<span className="text-[#f59e0b]">.</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-base md:text-lg mt-3 font-light tracking-wide">
|
||||
<p className="text-black/40 dark:text-white/40 text-base md:text-lg mt-3 font-light tracking-wide">
|
||||
a personal food journal
|
||||
</p>
|
||||
</motion.div>
|
||||
@@ -121,7 +121,7 @@ export default function HomePage() {
|
||||
placeholder="Search restaurants, cities..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-xl px-4 py-3 text-sm text-[#f5f5f5] placeholder-white/25 outline-none focus:border-[#f59e0b]/40 focus:bg-white/[0.06] transition-all"
|
||||
className="w-full bg-white dark:bg-white/[0.04] border border-black/[0.10] dark:border-white/[0.08] rounded-xl px-4 py-3 text-sm text-[#111] dark:text-[#f5f5f5] placeholder-black/30 dark:placeholder-white/25 outline-none focus:border-[#f59e0b]/60 dark:focus:border-[#f59e0b]/40 focus:bg-white dark:focus:bg-white/[0.06] transition-all shadow-sm dark:shadow-none"
|
||||
/>
|
||||
|
||||
{allCuisines.length > 0 && (
|
||||
@@ -133,7 +133,7 @@ export default function HomePage() {
|
||||
className={`text-xs font-semibold uppercase tracking-wider rounded-full px-3 py-1.5 transition-all border ${
|
||||
selectedCuisines.includes(c)
|
||||
? "bg-[#f59e0b] text-black border-[#f59e0b]"
|
||||
: "bg-white/[0.04] text-white/50 border-white/[0.08] hover:border-white/20"
|
||||
: "bg-white dark:bg-white/[0.04] text-black/50 dark:text-white/50 border-black/[0.10] dark:border-white/[0.08] hover:border-black/20 dark:hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
@@ -150,7 +150,7 @@ export default function HomePage() {
|
||||
className={`text-xs font-semibold rounded-full px-3 py-1.5 transition-all border ${
|
||||
selectedPrices.includes(p)
|
||||
? "bg-[#f59e0b] text-black border-[#f59e0b]"
|
||||
: "bg-white/[0.04] text-white/50 border-white/[0.08] hover:border-white/20"
|
||||
: "bg-white dark:bg-white/[0.04] text-black/50 dark:text-white/50 border-black/[0.10] dark:border-white/[0.08] hover:border-black/20 dark:hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{PRICE_LABELS[p - 1]}
|
||||
@@ -174,7 +174,7 @@ export default function HomePage() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-white/30 text-center py-24 text-sm"
|
||||
className="text-black/30 dark:text-white/30 text-center py-24 text-sm"
|
||||
>
|
||||
No restaurants found.
|
||||
</motion.p>
|
||||
|
||||
@@ -22,7 +22,7 @@ function formatDate(iso: string) {
|
||||
function RatingBar({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-1.5 bg-white/[0.08] rounded-full overflow-hidden">
|
||||
<div className="flex-1 h-1.5 bg-black/[0.08] dark:bg-white/[0.08] rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-[#f59e0b] rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
@@ -46,15 +46,15 @@ function VisitCard({ visit, index }: { visit: Visit; index: number }) {
|
||||
className="relative pl-6"
|
||||
>
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-white/[0.08]" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-black/[0.08] dark:bg-white/[0.08]" />
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-[-4px] top-3 w-2 h-2 rounded-full bg-[#f59e0b]" />
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 mb-4">
|
||||
<div className="rounded-2xl border border-black/[0.07] dark:border-white/[0.06] bg-white dark:bg-white/[0.03] p-5 mb-4 shadow-sm dark:shadow-none">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<p className="text-sm text-white/40">{formatDate(visit.date)}</p>
|
||||
<p className="text-sm text-black/40 dark:text-white/40">{formatDate(visit.date)}</p>
|
||||
{visit.price_paid && (
|
||||
<span className="text-xs text-white/30 shrink-0">
|
||||
<span className="text-xs text-black/30 dark:text-white/30 shrink-0">
|
||||
€{visit.price_paid}
|
||||
</span>
|
||||
)}
|
||||
@@ -63,12 +63,12 @@ function VisitCard({ visit, index }: { visit: Visit; index: number }) {
|
||||
<RatingBar rating={visit.rating} />
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-black/30 dark:text-white/30">
|
||||
Dishes
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{visit.dishes.map((dish, i) => (
|
||||
<li key={i} className="text-sm text-[#f5f5f5] flex items-start gap-2">
|
||||
<li key={i} className="text-sm text-[#111] dark:text-[#f5f5f5] flex items-start gap-2">
|
||||
<span className="text-[#f59e0b] mt-1 shrink-0">·</span>
|
||||
{dish}
|
||||
</li>
|
||||
@@ -77,8 +77,8 @@ function VisitCard({ visit, index }: { visit: Visit; index: number }) {
|
||||
</div>
|
||||
|
||||
{visit.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<p className="text-sm text-white/60 leading-relaxed italic">
|
||||
<div className="mt-4 pt-4 border-t border-black/[0.06] dark:border-white/[0.06]">
|
||||
<p className="text-sm text-black/50 dark:text-white/60 leading-relaxed italic">
|
||||
“{visit.notes}”
|
||||
</p>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export default function RestaurantPage() {
|
||||
if (notFound || !restaurant) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<p className="text-white/40">Restaurant not found</p>
|
||||
<p className="text-black/40 dark:text-white/40">Restaurant not found</p>
|
||||
<Link href="/" className="text-[#f59e0b] text-sm font-medium hover:underline">
|
||||
Back home
|
||||
</Link>
|
||||
@@ -161,7 +161,7 @@ export default function RestaurantPage() {
|
||||
>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-white/40 hover:text-white/70 transition-colors mb-8 flex items-center gap-2"
|
||||
className="text-sm text-black/40 dark:text-white/40 hover:text-black/70 dark:hover:text-white/70 transition-colors mb-8 flex items-center gap-2"
|
||||
>
|
||||
← back
|
||||
</button>
|
||||
@@ -174,18 +174,18 @@ export default function RestaurantPage() {
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-[#f5f5f5] leading-none">
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-[#111] dark:text-[#f5f5f5] leading-none">
|
||||
{restaurant.name}
|
||||
</h1>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[#f59e0b] font-black text-3xl leading-none">
|
||||
{avgRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-white/25 text-xs mt-1">avg</div>
|
||||
<div className="text-black/25 dark:text-white/25 text-xs mt-1">avg</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/40 text-base mt-2">
|
||||
<p className="text-black/40 dark:text-white/40 text-base mt-2">
|
||||
{restaurant.address} · {restaurant.city}, {restaurant.country}
|
||||
</p>
|
||||
|
||||
@@ -193,33 +193,32 @@ export default function RestaurantPage() {
|
||||
{restaurant.cuisine.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#f59e0b]/70 bg-[#f59e0b]/10 rounded-full px-3 py-1"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#f59e0b] bg-[#f59e0b]/10 rounded-full px-3 py-1"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-white/30 text-sm font-medium">
|
||||
<span className="text-black/30 dark:text-white/30 text-sm font-medium">
|
||||
{priceLabel(restaurant.price_range)}
|
||||
</span>
|
||||
<span className="text-white/25 text-sm">
|
||||
<span className="text-black/25 dark:text-white/25 text-sm">
|
||||
{restaurant.visits.length} {restaurant.visits.length === 1 ? "visit" : "visits"}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-8 border-t border-white/[0.06]" />
|
||||
<div className="my-8 border-t border-black/[0.06] dark:border-white/[0.06]" />
|
||||
|
||||
{/* Visits timeline */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-6 text-white/70">Visits</h2>
|
||||
<h2 className="text-lg font-bold mb-6 text-black/60 dark:text-white/70">Visits</h2>
|
||||
<div className="space-y-0">
|
||||
{sortedVisits.map((visit, i) => (
|
||||
<VisitCard key={visit.id} visit={visit} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user