Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export function Layout({ children, currentPage, onNavigate }: LayoutProps) {
</nav>

<div className="mt-auto space-y-2 border-t border-slate-200 pt-4 dark:border-slate-800">
<ThemeToggle variant="row" />
<div className="flex items-center justify-between rounded-lg px-3 py-2.5">
<span className="text-sm font-semibold text-slate-600 dark:text-slate-300">Theme</span>
<ThemeToggle variant="pill" />
</div>
<button
onClick={signOut}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold text-slate-600 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-300 dark:hover:bg-red-950/30 dark:hover:text-red-300"
Expand Down
18 changes: 11 additions & 7 deletions app/components/landing/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { ArrowRight, Bot, CheckCircle2, Database, ExternalLink, Lock, Search, Sparkles } from 'lucide-react'
import Link from '../ui/Link'
import { ThemeToggle } from '../ui/ThemeToggle'

const sources = [
{ label: 'Firebase Auth guide', type: 'Private', tag: 'Docs' },
Expand All @@ -25,13 +26,16 @@ const Hero = () => {
<a href="#trust" className="hover:text-slate-950 dark:hover:text-white">Trust</a>
<a href="#discover" className="hover:text-slate-950 dark:hover:text-white">Discover</a>
</nav>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
Open app
<ArrowRight className="h-4 w-4" />
</Link>
<div className="flex items-center gap-2">
<ThemeToggle variant="icon" />
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
Open app
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</header>

<div className="grid flex-1 items-center gap-10 py-14 lg:grid-cols-[0.88fr_1.12fr] lg:py-20">
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/LegalNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function LegalNav() {
<span className="text-base font-bold text-slate-900 dark:text-white">DumpIt</span>
</Link>
<div className="flex items-center gap-2">
<ThemeToggle variant="pill" />
<ThemeToggle variant="icon" />
<Link
href="/"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
Expand Down
148 changes: 100 additions & 48 deletions app/components/ui/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,130 @@
'use client'

import { Monitor, Moon, Sun } from 'lucide-react'
import { useTheme, type ThemeMode } from '../../contexts/ThemeContext'

const options: { mode: ThemeMode; Icon: typeof Sun; label: string }[] = [
{ mode: 'light', Icon: Sun, label: 'Light mode' },
{ mode: 'system', Icon: Monitor, label: 'System mode' },
{ mode: 'dark', Icon: Moon, label: 'Dark mode' },
]
import { Moon, Sun } from 'lucide-react'
import { useRef } from 'react'
import { useTheme } from '../../contexts/ThemeContext'

interface ThemeToggleProps {
/** 'pill' = compact 3-icon pill (default for navbars). 'row' = full-width row items for sidebars. */
variant?: 'pill' | 'row'
/**
* 'icon' — single icon button (Sun/Moon), clicking animates theme change.
* System preference is respected silently; user visible states are light/dark.
* 'pill' — compact 3-icon pill (Sun / Monitor / Moon), no View Transition animation.
* Kept for sidebar row usage.
*/
variant?: 'icon' | 'pill'
className?: string
}

export function ThemeToggle({ variant = 'pill', className = '' }: ThemeToggleProps) {
const { themeMode, setThemeMode } = useTheme()
/** Maximum radius needed to cover the entire viewport from any corner. */
function getMaxRadius(x: number, y: number): number {
const corners = [
Math.hypot(x, y),
Math.hypot(window.innerWidth - x, y),
Math.hypot(x, window.innerHeight - y),
Math.hypot(window.innerWidth - x, window.innerHeight - y),
]
return Math.max(...corners)
}

export function ThemeToggle({ variant = 'icon', className = '' }: ThemeToggleProps) {
const { resolvedTheme, themeMode, setThemeMode } = useTheme()
const buttonRef = useRef<HTMLButtonElement>(null)

const isDark = resolvedTheme === 'dark'

const handleAnimatedToggle = async () => {
const nextMode = isDark ? 'light' : 'dark'

// Fall back to instant toggle if View Transitions not supported
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setThemeMode(nextMode)
return
}

const btn = buttonRef.current
const rect = btn?.getBoundingClientRect()
const x = rect ? rect.left + rect.width / 2 : window.innerWidth / 2
const y = rect ? rect.top + rect.height / 2 : window.innerHeight / 2
const maxRadius = getMaxRadius(x, y)

const clipFrom = `circle(0px at ${x}px ${y}px)`
const clipTo = `circle(${maxRadius}px at ${x}px ${y}px)`

// Set the CSS custom property for the view-transition CSS to pick up
document.documentElement.style.setProperty('--vt-clip-from', clipFrom)
document.documentElement.style.setProperty('--vt-clip-to', clipTo)
document.documentElement.dataset.themeVt = 'active'

const transition = document.startViewTransition(() => {
setThemeMode(nextMode)
})

transition.finished.finally(() => {
delete document.documentElement.dataset.themeVt
})
}

if (variant === 'pill') {
// Compact pill showing all three states explicitly (for sidebar rows / settings)
const options = [
{ mode: 'light' as const, Icon: Sun, label: 'Light' },
{ mode: 'dark' as const, Icon: Moon, label: 'Dark' },
] as const

if (variant === 'row') {
return (
<div className={`space-y-0.5 ${className}`}>
<div
className={`inline-flex items-center rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800 ${className}`}
role="group"
aria-label="Theme selector"
>
{options.map(({ mode, Icon, label }) => {
const active = themeMode === mode
return (
<button
key={mode}
onClick={() => setThemeMode(mode)}
aria-pressed={active}
aria-label={label}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold transition-colors ${
aria-label={`${label} mode`}
title={`${label} mode`}
className={`relative flex h-7 w-7 items-center justify-center rounded-md transition-all ${
active
? 'bg-slate-100 text-slate-950 dark:bg-slate-800 dark:text-white'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-900 dark:hover:text-white'
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
{active && (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-blue-500" />
)}
<Icon className="h-3.5 w-3.5" />
</button>
)
})}
</div>
)
}

// Pill variant
// Default: animated icon button (Sun/Moon) with View Transition
return (
<div
className={`inline-flex items-center rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800 ${className}`}
role="group"
aria-label="Theme selector"
<button
ref={buttonRef}
onClick={handleAnimatedToggle}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className={`group relative flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:hover:text-white ${className}`}
>
{options.map(({ mode, Icon, label }) => {
const active = themeMode === mode
return (
<button
key={mode}
onClick={() => setThemeMode(mode)}
aria-pressed={active}
aria-label={label}
title={label}
className={`relative flex h-7 w-7 items-center justify-center rounded-md transition-all ${
active
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300'
}`}
>
<Icon className="h-3.5 w-3.5" />
</button>
)
})}
</div>
{/* Sun icon — shown in light mode */}
<Sun
className={`absolute h-4 w-4 transition-all duration-300 ${
isDark
? 'rotate-90 scale-0 opacity-0'
: 'rotate-0 scale-100 opacity-100'
}`}
/>
{/* Moon icon — shown in dark mode */}
<Moon
className={`absolute h-4 w-4 transition-all duration-300 ${
isDark
? 'rotate-0 scale-100 opacity-100'
: '-rotate-90 scale-0 opacity-0'
}`}
/>
</button>
)
}
24 changes: 24 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,32 @@
* {
@apply border-slate-200 dark:border-slate-800;
}

/* ── View Transition: animated theme toggle ────────────────────────────── */
/* Disable the default cross-fade so our clip-path runs cleanly */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

/* When the JS sets data-theme-vt="active", animate the new state */
[data-theme-vt='active']::view-transition-new(root) {
clip-path: var(--vt-clip-to, circle(100% at 50% 50%));
animation: vt-clip-reveal 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

@keyframes vt-clip-reveal {
from {
clip-path: var(--vt-clip-from, circle(0px at 50% 50%));
}
to {
clip-path: var(--vt-clip-to, circle(100% at 50% 50%));
}
}
}


@layer components {
.app-surface {
@apply border border-slate-200/80 bg-white/90 shadow-sm shadow-slate-200/60 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/20;
Expand Down
Loading