From b7fad1e69143735ce79c6e016ad25c82c1c803be Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 3 Feb 2026 18:30:32 -0500 Subject: [PATCH] feat: Implement glassmorphism UI with amber accents, add About and Copyright components, and update license to GPLv3. --- README.md | 2 +- next.config.js | 4 + src/app/layout.tsx | 10 +- src/app/page.tsx | 4 +- src/components/AboutModal.tsx | 69 ++++++++ src/components/CopyrightFooter.tsx | 22 +++ src/components/Drawer.tsx | 266 ++++++++++++++++++----------- src/components/LocateControl.tsx | 2 +- src/components/Map.tsx | 15 +- src/components/MapLoader.tsx | 13 +- src/components/MapStyleControl.tsx | 2 +- src/components/Navbar.tsx | 40 ++--- src/components/WelcomeModal.tsx | 38 +++-- src/components/ZoomControls.tsx | 6 +- src/lib/data.ts | 14 +- src/styles/globals.css | 11 ++ 16 files changed, 339 insertions(+), 179 deletions(-) create mode 100644 src/components/AboutModal.tsx create mode 100644 src/components/CopyrightFooter.tsx diff --git a/README.md b/README.md index 9f10a56..7edcac6 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,4 @@ bun start ## License -MIT +GPLv3 diff --git a/next.config.js b/next.config.js index 3dde8f3..6b25428 100644 --- a/next.config.js +++ b/next.config.js @@ -7,6 +7,10 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { output: "export", + basePath: process.env.NODE_ENV === "production" ? "/lewisburg-coffee" : undefined, + images: { + unoptimized: true, + }, }; export default config; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c423bb9..228fd31 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { type Metadata, type Viewport } from "next"; import { PT_Serif } from "next/font/google"; import { ThemeProvider } from "~/components/ThemeProvider"; +import { CopyrightFooter } from "~/components/CopyrightFooter"; import { env } from "~/env"; export const metadata: Metadata = { @@ -30,7 +31,7 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + {/* only load analytics on production */} {process.env.NODE_ENV === "production" && ( @@ -49,13 +50,10 @@ export default function RootLayout({ disableTransitionOnChange > {children} -
-
- © 2026 Sean O'Connor. All Rights Reserved. -
-
+ + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 7a48994..090271d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,12 +4,12 @@ import { useState, useEffect } from "react"; import MapLoader from "~/components/MapLoader"; import Drawer from "~/components/Drawer"; import Navbar from "~/components/Navbar"; -import { COFFEE_SHOPS } from "~/lib/data"; +import { COFFEE_SHOPS, type CoffeeShop } from "~/lib/data"; import { WelcomeModal } from "~/components/WelcomeModal"; export default function HomePage() { - const [selectedShop, setSelectedShop] = useState(null); + const [selectedShop, setSelectedShop] = useState(null); const [isDiscoveryOpen, setIsDiscoveryOpen] = useState(true); // Default to true for SSR const [mounted, setMounted] = useState(false); diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx new file mode 100644 index 0000000..e1e971f --- /dev/null +++ b/src/components/AboutModal.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "~/components/ui/dialog"; +import { Coffee, Github, Heart } from "lucide-react"; +import { Button } from "./ui/button"; + +interface AboutModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function AboutModal({ isOpen, onClose }: AboutModalProps) { + return ( + + + + + + Lewisburg Coffee Map + + + Discover the best coffee spots in Lewisburg, PA. + + +
+
+

About the Project

+

+ A modern, interactive map application built to help locals and visitors find their perfect cup of coffee. + Featuring real-time location services, dark/light mode, and a premium seamless experience. +

+
+ +
+

Attribution

+
    +
  • Created by Sean O'Connor
  • +
  • Built with Next.js 15 & React 19
  • +
  • Styled with Tailwind CSS v4
  • +
  • Map data provided by OpenStreetMap & Leaflet
  • +
  • Icons by Lucide React
  • +
+
+ +
+

+ © 2026 Sean O'Connor. All Rights Reserved. +

+

+ Licensed under GPLv3. +

+
+
+ +
+
+ ); +} diff --git a/src/components/CopyrightFooter.tsx b/src/components/CopyrightFooter.tsx new file mode 100644 index 0000000..4dae5c1 --- /dev/null +++ b/src/components/CopyrightFooter.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useState } from "react"; +import { AboutModal } from "./AboutModal"; + +export function CopyrightFooter() { + const [isAboutOpen, setIsAboutOpen] = useState(false); + + return ( + <> +
+ +
+ setIsAboutOpen(false)} /> + + ); +} diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index db46d65..6d4f860 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -1,4 +1,5 @@ -import { X, MapPin, Globe, Phone, Coffee, ExternalLink, Search, ChevronRight, ChevronLeft } from "lucide-react"; +import { X, MapPin, Globe, Phone, Coffee, ExternalLink, Search, ChevronRight, ChevronLeft, Navigation, Heart } from "lucide-react"; +import Image from "next/image"; import { Card } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -6,18 +7,7 @@ import { Separator } from "~/components/ui/separator"; import { Skeleton } from "~/components/ui/skeleton"; import { Input } from "~/components/ui/input"; import { useState, useEffect } from "react"; - -interface CoffeeShop { - id: number; - name: string; - description: string; - image: string; - address: string; - phone: string; - website: string; - lat: number; - lng: number; -} +import { type CoffeeShop } from "~/lib/data"; interface DrawerProps { shop: CoffeeShop | null; @@ -32,9 +22,34 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl const [searchQuery, setSearchQuery] = useState(""); const [imageLoading, setImageLoading] = useState(true); const [activeShop, setActiveShop] = useState(shop); + const [favorites, setFavorites] = useState>(new Set()); + + // Load favorites from local storage on mount + useEffect(() => { + const saved = localStorage.getItem('lewisburg-coffee-favorites'); + if (saved) { + try { + setFavorites(new Set(JSON.parse(saved))); + } catch (e) { + console.error("Failed to parse favorites", e); + } + } + }, []); + + // Save favorites to local storage whenever they change + const toggleFavorite = (id: number, e?: React.MouseEvent) => { + e?.stopPropagation(); + const newFavorites = new Set(favorites); + if (newFavorites.has(id)) { + newFavorites.delete(id); + } else { + newFavorites.add(id); + } + setFavorites(newFavorites); + localStorage.setItem('lewisburg-coffee-favorites', JSON.stringify(Array.from(newFavorites))); + }; // Update activeShop when shop changes, but only if it's not null - // This allows us to keep displaying the shop details while animating out useEffect(() => { if (shop) { setActiveShop(shop); @@ -47,23 +62,39 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl s.description.toLowerCase().includes(searchQuery.toLowerCase()) ); + const favoriteShops = filteredShops.filter(s => favorites.has(s.id)); + const otherShops = filteredShops.filter(s => !favorites.has(s.id)); + + const FADE_BOTTOM_STYLE = { + maskImage: 'linear-gradient(to bottom, black 50%, transparent 100%)', + WebkitMaskImage: 'linear-gradient(to bottom, black 50%, transparent 100%)' + }; + return (
- + {/* Details View */}
{activeShop && (
+ @@ -74,7 +105,7 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl onClose(); if (onToggleOpen) onToggleOpen(); }} - className="bg-background/20 hover:bg-background/40 text-foreground rounded-full h-8 w-8 backdrop-blur-md border border-border/50" + className="bg-black/40 hover:bg-black/60 text-white rounded-full h-8 w-8 backdrop-blur-md border border-glass-border" > @@ -86,77 +117,67 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl {imageLoading && (
- - + +
)}
- {activeShop.name} setImageLoading(false)} - style={{ - maskImage: 'linear-gradient(to bottom, black 50%, transparent 100%)', - WebkitMaskImage: 'linear-gradient(to bottom, black 50%, transparent 100%)' - }} + style={FADE_BOTTOM_STYLE} + sizes="(max-width: 640px) 100vw, 400px" />
{/* Content - Overlaps image slightly or just follows */} -
-

{activeShop.name}

- -
-
- - {activeShop.address} +
+
+

{activeShop.name}

+
+ +

{activeShop.address}

- {activeShop.phone && ( - - )} - {activeShop.website && ( - - )} +

+ {activeShop.description} +

- - -
-
-

About

-

- {activeShop.description} -

-
- - +
+ {activeShop.website && ( + + )} + {activeShop.phone && ( + + )} +
@@ -166,51 +187,56 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl {/* List View */}
-
- -

Discover Coffee

-
- +
+

+ + Coffee Shops +

+
+ setSearchQuery(e.target.value)} />
-
- {filteredShops.map((s) => ( -
onSelect(s)} - className="group flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer border border-transparent hover:border-border/50" - > -
- {s.name} -
-
-

{s.name}

-

{s.address}

-
- -
- ))} - {filteredShops.length === 0 && ( -
+
+ {filteredShops.length === 0 ? ( +

No shops found matching "{searchQuery}"

+ ) : ( + <> + {favoriteShops.length > 0 && ( +
+
+ + Favorites +
+ {favoriteShops.map((s) => ( + + ))} + +
+ )} + +
+ {favoriteShops.length > 0 && ( +
+ All Shops +
+ )} + {otherShops.map((s) => ( + + ))} +
+ )}
@@ -219,3 +245,41 @@ export default function Drawer({ shop, shops, onSelect, onClose, isOpen, onToggl
); } + +function ShopListItem({ shop, onSelect, isFavorite, onToggleFavorite }: { shop: CoffeeShop, onSelect: (s: CoffeeShop) => void, isFavorite: boolean, onToggleFavorite: (id: number, e: React.MouseEvent) => void }) { + return ( +
onSelect(shop)} + className="group flex items-start gap-3 p-3 rounded-lg hover:bg-glass-border transition-all cursor-pointer border border-transparent hover:border-amber-500/20 active:scale-[0.99]" + > +
+ {shop.name} + {isFavorite && ( +
+ +
+ )} +
+
+
+

{shop.name}

+ +
+

{shop.address}

+
+
+ ); +} diff --git a/src/components/LocateControl.tsx b/src/components/LocateControl.tsx index 81e21eb..11168af 100644 --- a/src/components/LocateControl.tsx +++ b/src/components/LocateControl.tsx @@ -72,7 +72,7 @@ export function LocateControl() { size="icon" onClick={handleLocate} disabled={loading} - className="bg-background/60 dark:bg-background/65 backdrop-blur-2xl border-border/50 dark:border-border/50 h-10 w-10 rounded-lg shadow-xl text-foreground" + className="rounded-xl border border-glass-border bg-glass-background text-glass-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-glass-border hover:text-white" > Locate me diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 608f5b5..473f616 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -8,18 +8,7 @@ import { useTheme } from "next-themes"; import { MapStyleControl } from "./MapStyleControl"; import { LocateControl } from './LocateControl'; import { ZoomControls } from "./ZoomControls"; - -interface CoffeeShop { - id: number; - name: string; - description: string; - lat: number; - lng: number; - address: string; - phone: string; - website: string; - image: string; -} +import { type CoffeeShop } from "~/lib/data"; interface MapProps { shops: CoffeeShop[]; @@ -185,7 +174,7 @@ const Map = ({ shops, onShopSelect, selectedShop, isDiscoveryOpen }: MapProps) = attributionControl={false} > -
+
diff --git a/src/components/MapLoader.tsx b/src/components/MapLoader.tsx index 7559d43..7497dda 100644 --- a/src/components/MapLoader.tsx +++ b/src/components/MapLoader.tsx @@ -3,18 +3,7 @@ import dynamic from "next/dynamic"; import { Skeleton } from "~/components/ui/skeleton"; import { Coffee, Loader2 } from "lucide-react"; - -interface CoffeeShop { - id: number; - name: string; - description: string; - lat: number; - lng: number; - address: string; - phone: string; - website: string; - image: string; -} +import { type CoffeeShop } from "~/lib/data"; interface MapLoaderProps { shops: CoffeeShop[]; diff --git a/src/components/MapStyleControl.tsx b/src/components/MapStyleControl.tsx index e563a42..925cd76 100644 --- a/src/components/MapStyleControl.tsx +++ b/src/components/MapStyleControl.tsx @@ -19,7 +19,7 @@ export function MapStyleControl({ currentStyle, onStyleChange }: MapStyleControl return ( - diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index d783d0b..f08a391 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -40,12 +40,12 @@ export default function Navbar({ isDiscoveryOpen, onToggleDiscovery }: NavbarPro return ( <> -
-
+
+
{/* Pulsing indicator ring - only during onboarding */} {isOnboarding && showTooltip && ( -
+
)} @@ -59,13 +59,13 @@ export default function Navbar({ isDiscoveryOpen, onToggleDiscovery }: NavbarPro setIsOnboarding(false); localStorage.setItem('discovery-panel-hint-seen', 'true'); }} - className={`h-10 w-10 rounded-lg hover:bg-background/40 transition-colors ${isDiscoveryOpen ? 'bg-background/40 text-primary' : 'text-muted-foreground'}`} + className={`h-10 w-10 rounded-lg transition-colors ${isDiscoveryOpen ? 'bg-glass-border text-amber-500' : 'text-glass-text-primary hover:bg-glass-border hover:text-white'}`} > Toggle Panel - +

Discover Coffee Shops

@@ -76,12 +76,12 @@ export default function Navbar({ isDiscoveryOpen, onToggleDiscovery }: NavbarPro className="flex items-center gap-3 px-2 cursor-pointer group absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" onClick={handleHeaderClick} > -
- +
+
-

Lewisburg Coffee Map

-

Find your perfect brew

+

Lewisburg Coffee Map

+

Find your perfect brew

@@ -92,30 +92,30 @@ export default function Navbar({ isDiscoveryOpen, onToggleDiscovery }: NavbarPro {/* About Dialog Overlay */} {showAbout && (
-
+
-
- +
+

Lewisburg Coffee Map

-

+

Discover the best coffee spots in Lewisburg, PA. Click on any marker to learn more about each location, or use the discovery panel to browse and search all available shops.

-
+
-
-

Features:

+
+

Features:

  • Interactive map with coffee shop locations
  • Search and filter coffee shops
  • @@ -123,9 +123,9 @@ export default function Navbar({ isDiscoveryOpen, onToggleDiscovery }: NavbarPro
  • Get directions to any shop
  • Light/Dark theme support
-
-

Map Data © OpenStreetMap contributors

-

Tiles © CARTO

+
+

Map Data © OpenStreetMap contributors

+

Tiles © CARTO

diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx index 0f9a945..28538e7 100644 --- a/src/components/WelcomeModal.tsx +++ b/src/components/WelcomeModal.tsx @@ -46,47 +46,49 @@ export function WelcomeModal() { return ( - + -
- +
+
Welcome to the Lewisburg Coffee Map - - Discover the best coffee spots in Lewisburg, PA. Click on markers, search shops, and find your perfect brew. + + Discover the best coffee spots in Lewisburg, PA. +
+ Created by Sean O'Connor
-
- +
+
-

Explore the Map

-

+

Explore the Map

+

Click any marker to see details, photos, and get directions.

-
- +
+
-

Search & Filter

-

+

Search & Filter

+

Use the discovery panel to browse and search all coffee shops.

-
- +
+
-

Find Your Location

-

+

Find Your Location

+

Use the locate button to center the map on your current position.

-
diff --git a/src/components/ZoomControls.tsx b/src/components/ZoomControls.tsx index 5667952..0e016ca 100644 --- a/src/components/ZoomControls.tsx +++ b/src/components/ZoomControls.tsx @@ -13,7 +13,7 @@ export function ZoomControls() { variant="outline" size="icon" onClick={() => map.zoomIn()} - className="bg-background/60 dark:bg-background/65 backdrop-blur-2xl border-border/50 dark:border-border/50 h-10 w-10 rounded-lg shadow-xl text-foreground hover:bg-background/70 dark:hover:bg-background/75" + className="rounded-xl border border-glass-border bg-glass-background text-glass-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-glass-border hover:text-white" > Zoom in @@ -22,7 +22,7 @@ export function ZoomControls() { variant="outline" size="icon" onClick={() => map.zoomOut()} - className="bg-background/60 dark:bg-background/65 backdrop-blur-2xl border-border/50 dark:border-border/50 h-10 w-10 rounded-lg shadow-xl text-foreground hover:bg-background/70 dark:hover:bg-background/75" + className="rounded-xl border border-glass-border bg-glass-background text-glass-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-glass-border hover:text-white" > Zoom out @@ -31,7 +31,7 @@ export function ZoomControls() { variant="outline" size="icon" onClick={() => map.setView([40.9645, -76.8845], 15)} - className="bg-background/60 dark:bg-background/65 backdrop-blur-2xl border-border/50 dark:border-border/50 h-10 w-10 rounded-lg shadow-xl text-foreground hover:bg-background/70 dark:hover:bg-background/75" + className="rounded-xl border border-glass-border bg-glass-background text-glass-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-glass-border hover:text-white" > Reset view diff --git a/src/lib/data.ts b/src/lib/data.ts index a67fb65..2571a23 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -1,4 +1,16 @@ -export const COFFEE_SHOPS = [ +export interface CoffeeShop { + id: number; + name: string; + description: string; + lat: number; + lng: number; + address: string; + phone: string; + website: string; + image: string; +} + +export const COFFEE_SHOPS: CoffeeShop[] = [ { id: 1, name: "Amami Kitchen", diff --git a/src/styles/globals.css b/src/styles/globals.css index 48b3fb5..f1f4c51 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -44,11 +44,22 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-glass-background: var(--glass-background); + --color-glass-border: var(--glass-border); + --color-glass-text-primary: var(--glass-text-primary); + --color-glass-text-secondary: var(--glass-text-secondary); } :root { --radius: 0.625rem; + /* ... existing vars ... */ + --glass-background: rgba(0, 0, 0, 0.7); + --glass-border: rgba(255, 255, 255, 0.15); + --glass-text-primary: rgba(255, 255, 255, 0.95); + --glass-text-secondary: rgba(255, 255, 255, 0.70); + --background: oklch(1 0 0); + /* ... */ --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0);