From 0e3e721bba5656b1602af04e8f76b23609546f41 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:26:02 -0400 Subject: [PATCH 01/16] new home page mock --- src/app/dashboard/page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 42c20f3..3942329 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,5 @@ -/** - * Placeholder - will be replaced in Phase 2 - */ +import { DashboardShell } from "@/components/layout/dashboard-shell"; + export default function DashboardPage() { - return
Placeholder
; + return ; } From c05d55c25b92c336206473f3b029c040a650342c Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:26:25 -0400 Subject: [PATCH 02/16] dashboard shell --- src/components/layout/dashboard-shell.tsx | 396 ++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 src/components/layout/dashboard-shell.tsx diff --git a/src/components/layout/dashboard-shell.tsx b/src/components/layout/dashboard-shell.tsx new file mode 100644 index 0000000..7de1ce3 --- /dev/null +++ b/src/components/layout/dashboard-shell.tsx @@ -0,0 +1,396 @@ +"use client"; + +import Link from "next/link"; +import { motion } from "framer-motion"; +import { + ArrowUpRight, + Bell, + ChevronRight, + Clock3, + Globe2, + LayoutGrid, + Search, + Shield, + Sparkles, + Star, + TrendingUp, + Trophy, + Users, + Waves, +} from "lucide-react"; + +const navItems = [ + { label: "Overview", href: "/dashboard", icon: LayoutGrid }, + { label: "Players", href: "/dashboard/players", icon: Users }, + { label: "News", href: "/dashboard/news", icon: Bell }, + { label: "World Cup", href: "/world-cup/history", icon: Trophy }, +]; + +const liveMatches = [ + { + competition: "UEFA Nations League", + minute: "67'", + home: { name: "Spain", logo: "ES" }, + away: { name: "France", logo: "FR" }, + score: "2 — 1", + status: "Live", + }, + { + competition: "Copa América Qualifiers", + minute: "21'", + home: { name: "Brazil", logo: "BR" }, + away: { name: "Uruguay", logo: "UY" }, + score: "1 — 0", + status: "Live", + }, + { + competition: "Premier League", + minute: "19:30", + home: { name: "Arsenal", logo: "ARS" }, + away: { name: "Chelsea", logo: "CHE" }, + score: "vs", + status: "Soon", + }, +]; + +const featuredPlayers = [ + { + name: "Jude Bellingham", + club: "Real Madrid", + role: "Progressive Midfielder", + rating: "8.9", + trend: "+0.7", + initials: "JB", + accent: "from-sky-400/30 via-blue-400/10 to-white/5", + }, + { + name: "Kylian Mbappé", + club: "Real Madrid", + role: "Explosive Forward", + rating: "9.2", + trend: "+1.1", + initials: "KM", + accent: "from-cyan-400/30 via-blue-400/10 to-white/5", + }, + { + name: "Lamine Yamal", + club: "Barcelona", + role: "Creative Winger", + rating: "8.6", + trend: "+0.4", + initials: "LY", + accent: "from-indigo-400/30 via-sky-400/10 to-white/5", + }, +]; + +const insightCards = [ + { + title: "AI Match Pulse", + value: "24 live narratives", + description: "Auto-generated momentum reads, tactical shifts, and player impact summaries.", + icon: Sparkles, + }, + { + title: "Scouting Terminal", + value: "148 tracked profiles", + description: "Hover cards, modal dossiers, and slide-over comparisons for every featured player.", + icon: Search, + }, + { + title: "Competition Atlas", + value: "52 active leagues", + description: "Premium logo treatment, standings snapshots, and tournament history in one shell.", + icon: Globe2, + }, +]; + +function TeamLogo({ code }: { code: string }) { + return ( +
+ {code} +
+ ); +} + +function PlayerOrb({ initials }: { initials: string }) { + return ( +
+
+ {initials} +
+ ); +} + +export function DashboardShell() { + return ( +
+
+
+ +
+ + +
+
+
+
+

+ Matchday command center +

+

+ A football terminal with Apple-grade polish. +

+
+ +
+
+ + Live sync every 30s +
+
+ + 12 matches active +
+
+
+
+ +
+
+ +
+
+
+ + Swale Intelligence Layer +
+

+ Built for elegant discovery, bold matchday detail, and premium player storytelling. +

+

+ This shell sets the visual language for the full frontend: textured navy surfaces, luminous blue accents, refined typography, and layered interactions for players, clubs, and competitions. +

+
+ +
+ {[ + { label: "Tracked players", value: "1.2K" }, + { label: "Competitions", value: "52" }, + { label: "AI summaries", value: "24/7" }, + ].map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+
+ + +
+
+

Terminal Preview

+

Player face popups

+
+ +
+ +
+ {featuredPlayers.map((player, index) => ( + +
+
+ +
+
+
+

{player.name}

+

{player.club} · {player.role}

+
+
+ {player.rating} +
+
+
+ + Hover card ready + + + Modal dossier ready + + + Drawer compare ready + +
+
+
+
+ Form trend + + + {player.trend} + +
+ + ))} +
+
+
+ +
+
+
+
+

Live board

+

Featured fixtures

+
+ + Open feed + + +
+ +
+ {liveMatches.map((match, index) => ( + +
+ {match.competition} + + {match.status} · {match.minute} + +
+
+
+ + {match.home.name} +
+
+ {match.score} +
+
+ {match.away.name} + +
+
+
+ ))} +
+
+ +
+ {insightCards.map((card, index) => { + const Icon = card.icon; + return ( + +
+
+

{card.title}

+

{card.value}

+

+ {card.description} +

+
+
+ +
+
+
+ ); + })} +
+
+
+
+
+
+ ); +} From 3770d33b26e92d6178368d1ba4a0cdaf1857f0db Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:26:36 -0400 Subject: [PATCH 03/16] dashboard layout --- src/app/dashboard/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 1db7277..0d2a4ed 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -6,5 +6,5 @@ export default function DashboardLayout({ }: Readonly<{ children: React.ReactNode; }>) { - return <>{children}; + return children; } From 355de79af3eabafd6a4f4b4bc851336e89c1a979 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:26:49 -0400 Subject: [PATCH 04/16] refined home page mock --- src/app/page.tsx | 572 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 516 insertions(+), 56 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..df268d8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,525 @@ -import Image from "next/image"; +"use client"; + +import Link from "next/link"; +import { motion, useScroll, useTransform } from "framer-motion"; +import { + ArrowRight, + CirclePlay, + Compass, + LockKeyhole, + ShieldCheck, + Sparkles, + Waves, +} from "lucide-react"; + +const floatingNav = [ + { label: "Home", href: "/" }, + { label: "Learn more", href: "#story" }, + { label: "Documentation", href: "#path" }, +]; + +const leagueMarks = [ + { name: "Premier League", short: "PL", tone: "bg-violet-100 text-violet-700" }, + { name: "La Liga", short: "LL", tone: "bg-rose-100 text-rose-700" }, + { name: "Serie A", short: "SA", tone: "bg-sky-100 text-sky-700" }, + { name: "Bundesliga", short: "BL", tone: "bg-red-100 text-red-700" }, + { name: "Champions League", short: "CL", tone: "bg-indigo-100 text-indigo-700" }, + { name: "World Cup", short: "WC", tone: "bg-emerald-100 text-emerald-700" }, +]; + +const terminalFeed = [ + { + label: "Live pulse", + value: "Spain 2–1 France · 67' · Williams creating overloads on the left", + tone: "text-emerald-500", + }, + { + label: "Player dossier", + value: "Bellingham form trend +0.7 over last 5 · 14 progressive carries", + tone: "text-blue-600", + }, + { + label: "Match note", + value: "Momentum swing detected after 58' substitution · midfield line pushed higher", + tone: "text-sky-500", + }, + { + label: "Competition atlas", + value: "52 leagues indexed · World Cup history, club form, and live matchday context", + tone: "text-slate-700", + }, +]; + +const storyCards = [ + { + eyebrow: "Quiet at first glance", + title: "A bright, calm front door for a deeper football world.", + body: "Swale should welcome casual fans and obsessive ones alike: live scores at a glance, then deeper layers for player form, tactical context, and tournament history.", + }, + { + eyebrow: "Built for movement", + title: "Scrolling should feel guided, not flat.", + body: "A matchday product should carry momentum. The page can reveal live boards, player stories, and competition identity with the same sense of flow that makes football compelling.", + }, + { + eyebrow: "Designed for fandom", + title: "Players, clubs, and competitions deserve stronger visual identity.", + body: "From league marks to player portraits and club crests, every layer should feel considered so the product looks like football, not generic software wearing football data.", + }, +]; + +const featureGrid = [ + { + eyebrow: "01 · Matchday pulse", + title: "A live board that feels composed, not chaotic.", + body: "Follow scores, momentum swings, and key events across multiple competitions without losing the thread of what matters in each match.", + }, + { + eyebrow: "02 · Player stories", + title: "Profiles that feel closer to editorial than utility software.", + body: "Track form, role, recent output, and stylistic notes for players across club and country, with room for face popups, dossiers, and comparisons.", + }, + { + eyebrow: "03 · Competition identity", + title: "Every league and tournament should have its own presence.", + body: "The Champions League should not feel like the World Cup, and the Premier League should not feel like a generic table. Swale can give each competition its own tone while staying coherent.", + }, + { + eyebrow: "04 · Guided motion", + title: "Movement should direct attention, not ask for it.", + body: "Motion can highlight a live match, elevate a player card, or reveal a tactical note at the right moment without overwhelming the page.", + }, +]; + +function FloatingOrb({ className }: { className: string }) { + return
; +} export default function Home() { + const { scrollYProgress } = useScroll(); + const heroY = useTransform(scrollYProgress, [0, 0.2], [0, 80]); + const heroOpacity = useTransform(scrollYProgress, [0, 0.18], [1, 0.72]); + const terminalY = useTransform(scrollYProgress, [0.08, 0.35], [80, -20]); + const terminalRotate = useTransform(scrollYProgress, [0.08, 0.35], [2.5, 0]); + const previewY = useTransform(scrollYProgress, [0.2, 0.5], [120, 0]); + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - + + + + + + + + + + + + + +

+
+
+
+ +
+
+

Swale

+

Football Intelligence

+
+
+ + + +
+ + Log in / Sign up + +
+
+ + +
- + + + + - Vercel logomark - Deploy Now - - +
+
+ + + +
+
+ Swale preview terminal +
+
+ +
+ +
+
+

Live command center

+

+ Follow the matchday, the players shaping it, and the competitions around it. +

+
+ + + +
+ +
+ {[ + { label: "Live matches", value: "12" }, + { label: "Tracked players", value: "1.2K" }, + { label: "AI summaries", value: "24/7" }, + ].map((item, index) => ( + +

{item.label}

+

{item.value}

+
+ ))} +
+ + +

Player interaction system

+

Hover cards, modal dossiers, and compare drawers.

+

+ Open a player and immediately understand recent form, role, club context, and why that player matters in the current match or tournament. +

+
+
+ + +
+
+

Pretend terminal

+

Swale OS preview

+
+ + Live mock + +
+ +
+ {terminalFeed.map((item, index) => ( + +

{item.label}

+

{item.value}

+
+ ))} +
+
+
+
+ + + + + - Documentation - -
-
-
+
+

Homepage preview

+

+ A calmer front page with a stronger football signal. +

+

+ Swale should immediately communicate what it covers: the biggest leagues, the biggest tournaments, the players in form, and the stories that shape a season. +

+ +
+

+ Built to span the game’s biggest stages +

+
+ {leagueMarks.map((league, index) => ( + +
+ {league.short} +
+ {league.name} +
+ ))} +
+
+
+ + +
+
+
+

Coverage snapshot

+

+ Built for club football, international football, and everything between. +

+
+ + + +
+ +
+ {[ + { label: "Live matches", value: "12" }, + { label: "Tracked players", value: "1.2K" }, + { label: "Competitions", value: "52" }, + { label: "Historical seasons", value: "1930 → now" }, + ].map((item, index) => ( + +

{item.label}

+

{item.value}

+
+ ))} +
+
+
+ + + +
+
+ {featureGrid.map((feature, index) => ( +
+

{feature.eyebrow}

+

+ {feature.title} +

+

{feature.body}

+
+ ))} +
+
+ +
+
+
+
+

A better rhythm

+

+ The page should unfold in layers, not blocks. +

+

+ Instead of stacking generic sections, the homepage now moves like a guided reveal: first the promise, then the matchday view, then the wider football world of players, leagues, and tournaments. +

+
+ + Scroll to move through the story +
+
+
+ +
+ {storyCards.map((card, index) => ( +
+

{card.eyebrow}

+

+ {card.title} +

+

{card.body}

+
+ ))} +
+
+
+ +
+
+
+
+
+
+

Where this goes next

+

+ A homepage with restraint, then a terminal with character. +

+

+ The goal is simple: keep the entry experience bright and composed, then let the logged-in product become richer with live boards, player intelligence, competition history, and stronger visual identity. +

+ +
+ + Open dashboard + + + + + Log in / Sign up + +
+
+
+ +
+
+ {[ + "Sharper team and competition identity", + "Player popups that feel premium instead of generic", + "A lighter, more tasteful blue-and-white system", + "Motion that guides attention while scrolling", + ].map((item, index) => ( +
+
+
+ 0{index + 1} +
+

{item}

+
+
+ ))} +
+
+
+
+
+
+ ); } From e2413c8363b50bbf05376747dafb642f392f3420 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:27:03 -0400 Subject: [PATCH 05/16] updated global styling --- src/app/globals.css | 211 ++++++++++++++++++++++++++------------------ 1 file changed, 127 insertions(+), 84 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a5ab127..ec181b7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,136 +1,179 @@ @import "tailwindcss"; :root { - /* Primary Colors */ - --primary: #00ff87; - --primary-dark: #00d966; - --primary-light: #33ff99; - - /* Background Shades */ - --background: #0d0d0d; - --background-secondary: #1a1a1a; - --background-tertiary: #262626; - - /* Foreground / Text */ - --foreground: #f5f5f5; - --foreground-secondary: #b0b0b0; - --foreground-tertiary: #808080; - - /* Accent Colors */ - --accent: #00ff87; - --accent-red: #ff4444; - --accent-yellow: #ffd700; - --accent-green: #00ff87; - - /* Borders & Dividers */ - --border: #333333; - --border-light: #404040; - - /* Semantic Colors */ - --success: #00ff87; - --warning: #ffd700; - --error: #ff4444; - --info: #3d00ff; - - /* Fonts */ + --background: #ffffff; + --background-secondary: #f8fbff; + --background-tertiary: #eef6ff; + --background-elevated: rgba(255, 255, 255, 0.82); + --foreground: #0f172a; + --foreground-secondary: #475569; + --foreground-muted: #7c8aa0; + --border: rgba(15, 23, 42, 0.08); + --border-strong: rgba(37, 99, 235, 0.18); + --primary: #2563eb; + --primary-strong: #0f172a; + --primary-soft: #dbeafe; + --accent: #38bdf8; + --accent-secondary: #1d4ed8; + --success: #10b981; + --warning: #f59e0b; + --error: #f43f5e; + --info: #60a5fa; + --shadow-color: rgba(15, 23, 42, 0.1); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: light) { - :root { - --background: #ffffff; - --background-secondary: #f9f9f9; - --background-tertiary: #f0f0f0; - --foreground: #0d0d0d; - --foreground-secondary: #4d4d4d; - --foreground-tertiary: #7f7f7f; - --border: #e0e0e0; - --border-light: #d0d0d0; - } -} - -/* Dark mode is default */ html { - color-scheme: dark; + color-scheme: light; + scroll-behavior: smooth; } body { - background-color: var(--background); + min-height: 100%; + background: + radial-gradient(circle at top, rgba(96, 165, 250, 0.12), transparent 28%), + radial-gradient(circle at 80% 10%, rgba(191, 219, 254, 0.45), transparent 22%), + linear-gradient(180deg, #ffffff 0%, #f8fbff 48%, #ffffff 100%); color: var(--foreground); font-family: var(--font-sans); line-height: 1.6; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } -/* Scrollbar Styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--background-secondary); +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.22; + background-image: + linear-gradient(rgba(15, 23, 42, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.03) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(180deg, black, transparent 92%); } -::-webkit-scrollbar-thumb { - background: var(--border-light); - border-radius: 4px; +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.1; + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.9) 0, transparent 24%), + radial-gradient(circle at 80% 0%, rgba(125, 211, 252, 0.18) 0, transparent 18%); + mix-blend-mode: screen; } -::-webkit-scrollbar-thumb:hover { - background: var(--foreground-secondary); +* { + border-color: var(--border); } -/* Selection */ ::selection { - background-color: var(--primary); - color: var(--background); + background-color: rgba(37, 99, 235, 0.16); + color: #0f172a; } -/* Focus visible for accessibility */ :focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } -/* Smooth scrolling */ -html { - scroll-behavior: smooth; -} - -/* Link defaults */ a { - color: var(--primary); + color: inherit; text-decoration: none; - transition: color 200ms ease; + transition: opacity 180ms ease, color 180ms ease, background-color 180ms ease, + border-color 180ms ease, transform 180ms ease; +} + +button, +input, +textarea, +select { + font: inherit; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: rgba(226, 232, 240, 0.7); +} + +::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.28); + border-radius: 999px; + border: 2px solid rgba(248, 250, 252, 0.9); } -a:hover { - color: var(--primary-light); +::-webkit-scrollbar-thumb:hover { + background: rgba(37, 99, 235, 0.35); } -/* Utility classes for common patterns */ .live-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +.live-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); + animation: pingDot 2s infinite; +} + +.glass-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.72)); + border: 1px solid var(--border); + box-shadow: 0 30px 80px var(--shadow-color); + backdrop-filter: blur(24px); +} + +.text-gradient { + background: linear-gradient(135deg, #0f172a 0%, #2563eb 55%, #38bdf8 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { - opacity: 0.5; + opacity: 0.55; } } -.live-dot { - display: inline-block; - width: 8px; - height: 8px; - background-color: var(--accent-green); - border-radius: 50%; - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +@keyframes pingDot { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); + } + 70% { + transform: scale(1.05); + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-14px); + } } From 2a1a34d02b8ac5b24ee5f3d8bd6e0dd81230f5d4 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 14 Jun 2026 21:27:14 -0400 Subject: [PATCH 06/16] dependencies additions --- package-lock.json | 43 +++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 44 insertions(+) diff --git a/package-lock.json b/package-lock.json index ff8d885..adb071c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3": "^7.9.0", + "framer-motion": "^12.40.0", "lucide-react": "^1.16.0", "next": "^16.2.6", "openai": "^6.39.0", @@ -4696,6 +4697,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6229,6 +6257,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index fd7ca96..fb76570 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3": "^7.9.0", + "framer-motion": "^12.40.0", "lucide-react": "^1.16.0", "next": "^16.2.6", "openai": "^6.39.0", From 6ced524a33f00ac37a40d8e36bf5f05534912d22 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:56:10 -0400 Subject: [PATCH 07/16] route validation --- src/lib/auth/validation.ts | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/lib/auth/validation.ts diff --git a/src/lib/auth/validation.ts b/src/lib/auth/validation.ts new file mode 100644 index 0000000..0aaa6b6 --- /dev/null +++ b/src/lib/auth/validation.ts @@ -0,0 +1,60 @@ +export interface AuthCredentials { + email: string; + password: string; +} + +export interface AuthValidationResult { + success: boolean; + error?: string; + credentials?: AuthCredentials; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 8; + +export function validateAuthCredentials(body: unknown): AuthValidationResult { + if (!body || typeof body !== "object") { + return { + success: false, + error: "Invalid request body", + }; + } + + const { email, password } = body as Record; + + if (typeof email !== "string" || email.trim().length === 0) { + return { + success: false, + error: "Email is required", + }; + } + + if (!EMAIL_REGEX.test(email.trim())) { + return { + success: false, + error: "Invalid email format", + }; + } + + if (typeof password !== "string" || password.length === 0) { + return { + success: false, + error: "Password is required", + }; + } + + if (password.length < MIN_PASSWORD_LENGTH) { + return { + success: false, + error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + }; + } + + return { + success: true, + credentials: { + email: email.trim().toLowerCase(), + password, + }, + }; +} From c13606d06edf5de6dd75b2fac43073bfdfee3726 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:56:51 -0400 Subject: [PATCH 08/16] updating current sign-up / sign-in routes --- src/app/api/auth/login/route.ts | 23 +++++--------- src/app/api/auth/signup/route.ts | 53 +++----------------------------- 2 files changed, 13 insertions(+), 63 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 38045a0..05433fb 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,3 +1,4 @@ +import { validateAuthCredentials } from "@/lib/auth/validation"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { NextRequest, NextResponse } from "next/server"; @@ -10,28 +11,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { email, password } = body as { - email?: unknown; - password?: unknown; - }; - if ( - typeof email !== "string" || - typeof password !== "string" || - !email || - !password - ) { + const validation = validateAuthCredentials(body); + + if (!validation.success || !validation.credentials) { return NextResponse.json( - { error: "Email and password are required" }, + { error: validation.error ?? "Invalid credentials" }, { status: 400 } ); } const supabase = await createServerSupabaseClient(); - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); + const { data, error } = await supabase.auth.signInWithPassword( + validation.credentials + ); if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index f6ece3e..f3aee2d 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,9 +1,9 @@ +import { validateAuthCredentials } from "@/lib/auth/validation"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { try { - // Parse request body let body: unknown; try { body = await request.json(); @@ -14,61 +14,18 @@ export async function POST(request: NextRequest) { ); } - // Validate body is an object - if (!body || typeof body !== "object") { - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 } - ); - } - - const { email, password } = body as Record; - - // Validate email and password exist and are strings - if (typeof email !== "string" || email.trim().length === 0) { - return NextResponse.json( - { error: "Email is required and must be a string" }, - { status: 400 } - ); - } - - if (typeof password !== "string" || password.length === 0) { - return NextResponse.json( - { error: "Password is required and must be a string" }, - { status: 400 } - ); - } - - // Validate email format - simple check, Supabase will do deeper validation - const trimmedEmail = email.trim(); - if (!trimmedEmail.includes("@") || !trimmedEmail.includes(".")) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 } - ); - } - const [localPart, domain] = trimmedEmail.split("@"); - if (!localPart || !domain || localPart.length === 0 || domain.length < 3) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 } - ); - } + const validation = validateAuthCredentials(body); - // Validate password length - if (password.length < 8) { + if (!validation.success || !validation.credentials) { return NextResponse.json( - { error: "Password must be at least 8 characters" }, + { error: validation.error ?? "Invalid credentials" }, { status: 400 } ); } const supabase = await createServerSupabaseClient(); - const { data, error } = await supabase.auth.signUp({ - email: email.trim(), - password, - }); + const { data, error } = await supabase.auth.signUp(validation.credentials); if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); From 8fb59e4ad6bda8f31442cb643982535cb83487ca Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:57:03 -0400 Subject: [PATCH 09/16] logout route creation --- src/app/api/auth/logout/route.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/app/api/auth/logout/route.ts diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f981cf5 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; + +export async function POST() { + try { + const supabase = await createServerSupabaseClient(); + const { error } = await supabase.auth.signOut(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} From 2bf17edf6ef6da742ed1addd86845f25121107f0 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:57:50 -0400 Subject: [PATCH 10/16] login/sign-up page scaffold --- src/app/auth/login/page.tsx | 29 +++++++++++++++++++++++++++++ src/app/auth/signup/page.tsx | 29 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/app/auth/login/page.tsx create mode 100644 src/app/auth/signup/page.tsx diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 0000000..691d9f1 --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function LoginPage() { + return ( + + New to Swale? {" "} + + Create an account + + + } + > +
+

Login

+

+ Sign in to continue. +

+

+ Your login form will live here in the next commit. +

+
+
+ ); +} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..48da718 --- /dev/null +++ b/src/app/auth/signup/page.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function SignupPage() { + return ( + + Already have an account? {" "} + + Sign in + + + } + > +
+

Sign up

+

+ Create your access. +

+

+ Your signup form will live here in the next commit. +

+
+
+ ); +} From e2d02aaa9a652fa774274200326c2a2d22f02464 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:58:15 -0400 Subject: [PATCH 11/16] shared auth shell --- src/components/auth/auth-shell.tsx | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/components/auth/auth-shell.tsx diff --git a/src/components/auth/auth-shell.tsx b/src/components/auth/auth-shell.tsx new file mode 100644 index 0000000..13ebb9b --- /dev/null +++ b/src/components/auth/auth-shell.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { Waves } from "lucide-react"; + +interface AuthShellProps { + title: string; + subtitle: string; + footer: React.ReactNode; + children: React.ReactNode; +} + +export function AuthShell({ + title, + subtitle, + footer, + children, +}: AuthShellProps) { + return ( +
+
+
+ +
+
+ +
+ +
+
+

+ Swale +

+

+ Football Intelligence +

+
+ + + + Back home + +
+ +
+
+

+ Secure access +

+

+ {title} +

+

+ {subtitle} +

+ +
+ {[ + "Live matchday boards across major competitions", + "Player form, scouting notes, and comparison views", + "World Cup history, club context, and richer football storytelling", + ].map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+
+ {children} +
+ {footer} +
+
+
+
+
+
+ ); +} From c08218141a46470bca9bd7a78bee9aa1deb3de20 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 21:59:02 -0400 Subject: [PATCH 12/16] reusable auth form --- src/app/auth/login/page.tsx | 15 +++--- src/app/auth/signup/page.tsx | 15 +++--- src/components/auth/auth-form.tsx | 86 +++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 src/components/auth/auth-form.tsx diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 691d9f1..105cb32 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { AuthForm } from "@/components/auth/auth-form"; import { AuthShell } from "@/components/auth/auth-shell"; export default function LoginPage() { @@ -15,15 +16,11 @@ export default function LoginPage() { } > -
-

Login

-

- Sign in to continue. -

-

- Your login form will live here in the next commit. -

-
+ ); } diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 48da718..ce9566c 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { AuthForm } from "@/components/auth/auth-form"; import { AuthShell } from "@/components/auth/auth-shell"; export default function SignupPage() { @@ -15,15 +16,11 @@ export default function SignupPage() { } > -
-

Sign up

-

- Create your access. -

-

- Your signup form will live here in the next commit. -

-
+ ); } diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx new file mode 100644 index 0000000..8e337c1 --- /dev/null +++ b/src/components/auth/auth-form.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { AlertCircle, Loader2 } from "lucide-react"; + +interface AuthFormProps { + mode: "login" | "signup"; + submitLabel: string; + helperText: string; + isSubmitting?: boolean; + error?: string | null; + success?: string | null; + onSubmit?: (event: React.FormEvent) => void; +} + +export function AuthForm({ + mode, + submitLabel, + helperText, + isSubmitting = false, + error, + success, + onSubmit, +}: AuthFormProps) { + return ( +
+
+

+ {mode === "login" ? "Login" : "Sign up"} +

+

+ {mode === "login" ? "Sign in to continue." : "Create your access."} +

+

{helperText}

+
+ +
+ + + +
+ + {error ? ( +
+ +

{error}

+
+ ) : null} + + {success ? ( +
+ {success} +
+ ) : null} + + +
+ ); +} From 9ac27402a9bb30310fb03a6ffc22186b2a0acb3c Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 22:03:20 -0400 Subject: [PATCH 13/16] reference updates --- src/app/auth/signup/page.tsx | 27 +++------------------------ src/app/page.tsx | 4 ++-- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index ce9566c..b0be768 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,26 +1,5 @@ -import Link from "next/link"; -import { AuthForm } from "@/components/auth/auth-form"; -import { AuthShell } from "@/components/auth/auth-shell"; +import { redirect } from "next/navigation"; -export default function SignupPage() { - return ( - - Already have an account? {" "} - - Sign in - - - } - > - - - ); +export default function LegacyAuthSignupPage() { + redirect("/signup"); } diff --git a/src/app/page.tsx b/src/app/page.tsx index df268d8..cb4b490 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -144,7 +144,7 @@ export default function Home() {
Log in / Sign up @@ -484,7 +484,7 @@ export default function Home() { From 6fb1644e5b21cc16339bd5f8184887053341aea0 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 22:03:45 -0400 Subject: [PATCH 14/16] login / signup initial frontend --- src/app/auth/login/page.tsx | 56 +++++++++++++++++++++++-- src/app/login/page.tsx | 71 ++++++++++++++++++++++++++++++++ src/app/signup/page.tsx | 82 +++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/app/login/page.tsx create mode 100644 src/app/signup/page.tsx diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 105cb32..037226d 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,16 +1,61 @@ +"use client"; + import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; import { AuthForm } from "@/components/auth/auth-form"; import { AuthShell } from "@/components/auth/auth-shell"; -export default function LoginPage() { +export default function LegacyAuthLoginPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = (await response.json()) as { error?: string }; + + if (!response.ok) { + setError(result.error ?? "Unable to sign in"); + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + return ( - New to Swale? {" "} - + New to Swale?{" "} + Create an account @@ -18,8 +63,11 @@ export default function LoginPage() { > ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d1f51ea --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AuthForm } from "@/components/auth/auth-form"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function LoginPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = (await response.json()) as { error?: string }; + + if (!response.ok) { + setError(result.error ?? "Unable to sign in"); + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + + New to Swale?{" "} + + Create an account + + + } + > + + + ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..99f05b7 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AuthForm } from "@/components/auth/auth-form"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function SignupPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = (await response.json()) as { + error?: string; + }; + + if (!response.ok) { + setError(result.error ?? "Unable to create account"); + return; + } + + setSuccess( + "Account created. You can now sign in with your email and password." + ); + + setTimeout(() => { + router.push("/login"); + router.refresh(); + }, 1200); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + + Already have an account?{" "} + + Sign in + + + } + > + + + ); +} From 6ded03288b9295013bb9c2a7a239099dfeaaf000 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 22:05:33 -0400 Subject: [PATCH 15/16] middleware refactoring --- src/app/auth/login/page.tsx | 74 ------------------------------------ src/app/auth/signup/page.tsx | 5 --- src/middleware.ts | 48 +++++++++++++++++------ 3 files changed, 37 insertions(+), 90 deletions(-) delete mode 100644 src/app/auth/login/page.tsx delete mode 100644 src/app/auth/signup/page.tsx diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx deleted file mode 100644 index 037226d..0000000 --- a/src/app/auth/login/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { AuthForm } from "@/components/auth/auth-form"; -import { AuthShell } from "@/components/auth/auth-shell"; - -export default function LegacyAuthLoginPage() { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setError(null); - setIsSubmitting(true); - - const formData = new FormData(event.currentTarget); - const email = formData.get("email"); - const password = formData.get("password"); - - try { - const response = await fetch("/api/auth/login", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ email, password }), - }); - - const result = (await response.json()) as { error?: string }; - - if (!response.ok) { - setError(result.error ?? "Unable to sign in"); - return; - } - - router.push("/dashboard"); - router.refresh(); - } catch { - setError("Something went wrong. Please try again."); - } finally { - setIsSubmitting(false); - } - } - - return ( - - New to Swale?{" "} - - Create an account - - - } - > - - - ); -} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx deleted file mode 100644 index b0be768..0000000 --- a/src/app/auth/signup/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function LegacyAuthSignupPage() { - redirect("/signup"); -} diff --git a/src/middleware.ts b/src/middleware.ts index 5102fde..2ba3126 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,21 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +const PROTECTED_ROUTES = ["/dashboard"]; +const AUTH_ROUTES = ["/login", "/signup"]; + +function isProtectedRoute(pathname: string) { + return PROTECTED_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ); +} + +function isAuthRoute(pathname: string) { + return AUTH_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ); +} + export async function middleware(request: NextRequest) { const supabaseResponse = NextResponse.next({ request, @@ -23,11 +38,29 @@ export async function middleware(request: NextRequest) { } ); - // Check session + let user = null; + try { - await supabase.auth.getUser(); + const { + data: { user: currentUser }, + } = await supabase.auth.getUser(); + user = currentUser; } catch { - return supabaseResponse; + user = null; + } + + const { pathname } = request.nextUrl; + + if (!user && isProtectedRoute(pathname)) { + const url = request.nextUrl.clone(); + url.pathname = "/login"; + return NextResponse.redirect(url); + } + + if (user && isAuthRoute(pathname)) { + const url = request.nextUrl.clone(); + url.pathname = "/dashboard"; + return NextResponse.redirect(url); } return supabaseResponse; @@ -35,13 +68,6 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public folder - */ - "/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)", + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|webp|gif)$).*)", ], }; From 944e77b6d0833bb662d898be105749fad0e8ef04 Mon Sep 17 00:00:00 2001 From: Ori Marx Date: Sun, 21 Jun 2026 22:06:44 -0400 Subject: [PATCH 16/16] session aware UI & logout flow --- src/app/page.tsx | 9 ++++-- src/components/auth/logout-button.tsx | 28 +++++++++++++++++ src/components/layout/dashboard-shell.tsx | 10 ++++++ src/lib/hooks/useAuth.ts | 38 ++++++++++++++++++++--- 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/components/auth/logout-button.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index cb4b490..57716cc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,6 +11,7 @@ import { Sparkles, Waves, } from "lucide-react"; +import { useAuth } from "@/lib/hooks/useAuth"; const floatingNav = [ { label: "Home", href: "/" }, @@ -97,12 +98,16 @@ function FloatingOrb({ className }: { className: string }) { export default function Home() { const { scrollYProgress } = useScroll(); + const { isAuthenticated, loading } = useAuth(); const heroY = useTransform(scrollYProgress, [0, 0.2], [0, 80]); const heroOpacity = useTransform(scrollYProgress, [0, 0.18], [1, 0.72]); const terminalY = useTransform(scrollYProgress, [0.08, 0.35], [80, -20]); const terminalRotate = useTransform(scrollYProgress, [0.08, 0.35], [2.5, 0]); const previewY = useTransform(scrollYProgress, [0.2, 0.5], [120, 0]); + const primaryHref = !loading && isAuthenticated ? "/dashboard" : "/login"; + const primaryLabel = !loading && isAuthenticated ? "Open dashboard" : "Log in / Sign up"; + return (
@@ -144,10 +149,10 @@ export default function Home() {
- Log in / Sign up + {primaryLabel}
diff --git a/src/components/auth/logout-button.tsx b/src/components/auth/logout-button.tsx new file mode 100644 index 0000000..390a334 --- /dev/null +++ b/src/components/auth/logout-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { useAuth } from "@/lib/hooks/useAuth"; + +export function LogoutButton() { + const router = useRouter(); + const { signOut, isSigningOut } = useAuth(); + + async function handleLogout() { + await signOut(); + router.push("/login"); + router.refresh(); + } + + return ( + + ); +} diff --git a/src/components/layout/dashboard-shell.tsx b/src/components/layout/dashboard-shell.tsx index 7de1ce3..3d4acc7 100644 --- a/src/components/layout/dashboard-shell.tsx +++ b/src/components/layout/dashboard-shell.tsx @@ -18,6 +18,8 @@ import { Users, Waves, } from "lucide-react"; +import { LogoutButton } from "@/components/auth/logout-button"; +import { useAuth } from "@/lib/hooks/useAuth"; const navItems = [ { label: "Overview", href: "/dashboard", icon: LayoutGrid }, @@ -122,6 +124,8 @@ function PlayerOrb({ initials }: { initials: string }) { } export function DashboardShell() { + const { user } = useAuth(); + return (
@@ -203,6 +207,12 @@ export function DashboardShell() { 12 matches active
+ {user?.email ? ( +
+ Signed in as {user.email} +
+ ) : null} +
diff --git a/src/lib/hooks/useAuth.ts b/src/lib/hooks/useAuth.ts index bea1c61..b92745f 100644 --- a/src/lib/hooks/useAuth.ts +++ b/src/lib/hooks/useAuth.ts @@ -1,12 +1,13 @@ "use client"; import { createClient } from "@/lib/supabase/client"; -import { useEffect, useMemo, useState } from "react"; -import type { Session } from "@supabase/supabase-js"; +import type { Session, User } from "@supabase/supabase-js"; +import { useCallback, useEffect, useMemo, useState } from "react"; export function useAuth() { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); + const [isSigningOut, setIsSigningOut] = useState(false); const supabase = useMemo(() => createClient(), []); useEffect(() => { @@ -18,10 +19,39 @@ export function useAuth() { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session); + setLoading(false); }); - return () => subscription?.unsubscribe(); + return () => subscription.unsubscribe(); }, [supabase]); - return { session, loading }; + const signOut = useCallback(async () => { + setIsSigningOut(true); + + try { + await fetch("/api/auth/logout", { + method: "POST", + }); + await supabase.auth.signOut(); + setSession(null); + } finally { + setIsSigningOut(false); + } + }, [supabase]); + + return { + session, + user: session?.user ?? null, + loading, + isAuthenticated: Boolean(session?.user), + isSigningOut, + signOut, + } satisfies { + session: Session | null; + user: User | null; + loading: boolean; + isAuthenticated: boolean; + isSigningOut: boolean; + signOut: () => Promise; + }; }