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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user