diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 485f986..dd5a2eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,4 +43,4 @@ jobs: env: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - NEXT_PUBLIC_APP_URL: https://pomodoro-jam.vercel.app + NEXT_PUBLIC_APP_URL: https://bonfirefocus.vercel.app diff --git a/CLAUDE.md b/CLAUDE.md index 0f78fa1..0d62f0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# PomodoroJam +# Bonfire A real-time shared Pomodoro timer app built with Next.js 14. diff --git a/ROADMAP.md b/ROADMAP.md index e1e1306..3e7265d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ -# PomodoroJam — Product Roadmap +# Bonfire — Product Roadmap -> Vision: Transform PomodoroJam from a shared timer into a focus social network — +> Vision: Transform Bonfire from a shared timer into a focus social network — > where sessions become rooms, productivity becomes identity, and your streak with > a friend is worth sharing. > @@ -277,7 +277,7 @@ > Makes the app sticky for new users who have zero friends on it yet. ### 5a. Onboarding flow -- [ ] First-time visitor sees a 3-step onboarding: what is PomodoroJam, how rooms work, the bonfire mechanic +- [ ] First-time visitor sees a 3-step onboarding: what is Bonfire, how rooms work, the bonfire mechanic - [ ] "Aha moment" designed in: show a live room with the fire burning before they even sign up - [ ] Skip-friendly — should never feel forced - [ ] After sign-up: prompt to follow 1-2 suggested users (seeded accounts that are always "active") diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 0121b0c..ae8dce9 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -26,7 +26,7 @@ export async function GET(request: NextRequest) { const pomodoros = clampInt(searchParams.get('pomodoros')) const streak = clampInt(searchParams.get('streak')) const hours = clampInt(searchParams.get('hours')) - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://pomodoro-jam.vercel.app' + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://bonfirefocus.vercel.app' const displayUrl = appUrl.replace(/^https?:\/\//, '') if (type === 'stats') { @@ -34,7 +34,7 @@ export async function GET(request: NextRequest) { (
-
🍅 PomodoroJam
+
Bonfire
{username}
Focus stats
@@ -71,7 +71,7 @@ export async function GET(request: NextRequest) {
- PomodoroJam + Bonfire
{host} invited you to a focus room @@ -146,8 +146,8 @@ export async function GET(request: NextRequest) { marginBottom: '20px', }} > - Pomodoro - Jam + Bon + fire
{/* Session name or default tagline */} diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 6be94e2..c0e2f5e 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -14,6 +14,7 @@ const CreateSessionSchema = z.object({ jam_mode: z.boolean().optional(), session_mode: z.enum(['host', 'jam', 'solo']).optional(), is_public: z.boolean().optional(), + display_name: z.string().trim().min(1).max(40).nullable().optional(), }) export async function POST(request: Request) { @@ -41,6 +42,8 @@ export async function POST(request: Request) { if (profile) { hostName = profile.display_name ?? profile.username ?? 'Guest' } + } else if (parsed.data.display_name) { + hostName = parsed.data.display_name } const sessionId = generateSessionId() diff --git a/app/explore/page.tsx b/app/explore/page.tsx index 4493eb0..60c1e50 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import Link from 'next/link' +import { Flame } from 'lucide-react' import { createClient } from '@/lib/supabase/server' import { Logo } from '@/components/ui/Logo' import { ThemeToggle } from '@/components/ui/ThemeToggle' @@ -139,7 +140,7 @@ export default async function ExplorePage() {
{totalFocusing}
-
+
Focusing Now
@@ -152,7 +153,7 @@ export default async function ExplorePage() { className="flex flex-col items-center justify-center text-center py-24 rounded-3xl" style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border)' }} > -
🍅
+

No live rooms right now

@@ -200,11 +201,16 @@ export default async function ExplorePage() {
- {/* Middle: room title */} + {/* Middle: room title + host */}

{session.title ?? 'Focus Room'}

+ {session.host_name && ( +

+ hosted by {session.host_name} +

+ )}
{/* Bottom: avatar stack + count */} @@ -248,7 +254,7 @@ export default async function ExplorePage() {
- {count === 1 ? 'Focusing now' : `${count} focusing${count >= 5 ? ' 🔥' : ''}`} + {count === 1 ? 'Focusing now' : `${count} focusing`}
diff --git a/app/globals.css b/app/globals.css index abeb778..4cb2101 100644 --- a/app/globals.css +++ b/app/globals.css @@ -46,7 +46,7 @@ /* Fonts */ --font-dm-sans: 'DM Sans', system-ui, sans-serif; - --font-syne: 'Syne', sans-serif; + --font-display: 'Plus Jakarta Sans', sans-serif; --font-mono: 'JetBrains Mono', monospace; --font-space-mono: var(--font-mono); /* backward compat */ } @@ -60,7 +60,7 @@ --bg-elevated: #222220; --text-primary: #F5F5F0; --text-secondary: #9B9B8E; - --text-muted: #5A5A50; + --text-muted: #7E7E72; --accent: #FF5533; --accent-soft: rgba(255, 85, 51, 0.1); --accent-hover: #FF6644; diff --git a/app/layout.tsx b/app/layout.tsx index a721871..4ad2fe2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from 'next' -import { DM_Sans, Syne, JetBrains_Mono } from 'next/font/google' +import { DM_Sans, Plus_Jakarta_Sans, JetBrains_Mono } from 'next/font/google' import { ThemeProvider } from 'next-themes' import { Analytics } from '@vercel/analytics/next' import { FaviconInit } from '@/components/ui/FaviconInit' @@ -11,9 +11,9 @@ const dmSans = DM_Sans({ display: 'swap', }) -const syne = Syne({ +const plusJakartaSans = Plus_Jakarta_Sans({ subsets: ['latin'], - variable: '--font-syne', + variable: '--font-display', display: 'swap', }) @@ -23,46 +23,45 @@ const jetbrainsMono = JetBrains_Mono({ display: 'swap', }) -const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://pomodoro-jam.vercel.app' +const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://bonfirefocus.vercel.app' export const metadata: Metadata = { metadataBase: new URL(appUrl), title: { - default: 'PomodoroJam: Focus Together', - template: '%s | PomodoroJam', + default: 'Bonfire: Focus Together', + template: '%s | Bonfire', }, description: - 'A shared Pomodoro timer for friends. Start a room, share the link, focus in sync.', + 'A shared focus timer for friends. Start a room, share the link, focus in sync.', keywords: ['pomodoro', 'focus', 'productivity', 'timer', 'shared', 'real-time'], - authors: [{ name: 'PomodoroJam' }], - creator: 'PomodoroJam', + authors: [{ name: 'Bonfire' }], + creator: 'Bonfire', openGraph: { type: 'website', locale: 'en_US', url: appUrl, - siteName: 'PomodoroJam', - title: 'PomodoroJam: Focus Together', + siteName: 'Bonfire', + title: 'Bonfire: Focus Together', description: 'Real-time shared Pomodoro timer. Focus with friends.', images: [ { url: '/api/og', width: 1200, height: 630, - alt: 'PomodoroJam', + alt: 'Bonfire', }, ], }, twitter: { card: 'summary_large_image', - title: 'PomodoroJam: Focus Together', + title: 'Bonfire: Focus Together', description: 'Real-time shared Pomodoro timer. Focus with friends.', images: ['/api/og'], }, manifest: '/manifest.json', appleWebApp: { - capable: true, statusBarStyle: 'black-translucent', - title: 'PomodoroJam', + title: 'Bonfire', }, icons: { apple: '/apple-touch-icon.png', @@ -70,6 +69,9 @@ export const metadata: Metadata = { alternates: { canonical: appUrl, }, + other: { + 'mobile-web-app-capable': 'yes', + }, } export const viewport: Viewport = { @@ -90,7 +92,7 @@ export default function RootLayout({ @@ -100,7 +102,7 @@ export default function RootLayout({ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebApplication', - name: 'PomodoroJam', + name: 'Bonfire', url: appUrl, description: 'A shared Pomodoro timer for friends. Start a session, share the link, focus in sync.', applicationCategory: 'ProductivityApplication', diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..04d13de --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,232 @@ +'use client' + +import { Suspense, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { Mail } from 'lucide-react' +import { Logo } from '@/components/ui/Logo' +import { ThemeToggle } from '@/components/ui/ThemeToggle' +import { createClient } from '@/lib/supabase/client' + +const GoogleIcon = () => ( + + + + + + +) + +const GitHubIcon = () => ( + + + +) + +function LoginContent() { + const searchParams = useSearchParams() + const next = searchParams.get('next') ?? '/' + const supabase = useMemo(() => createClient(), []) + + const [mode, setMode] = useState<'signup' | 'signin'>('signup') + const [email, setEmail] = useState('') + const [emailSent, setEmailSent] = useState(false) + const [emailError, setEmailError] = useState('') + const [emailLoading, setEmailLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'github' | 'google' | null>(null) + + const handleOAuth = async (provider: 'github' | 'google') => { + setOauthLoading(provider) + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`, + }, + }) + if (error) setOauthLoading(null) + } + + const handleEmail = async (e: React.FormEvent) => { + e.preventDefault() + if (!email.trim()) return + setEmailLoading(true) + setEmailError('') + const { error } = await supabase.auth.signInWithOtp({ + email: email.trim(), + options: { + emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`, + }, + }) + setEmailLoading(false) + if (error) { + setEmailError(error.message) + } else { + setEmailSent(true) + } + } + + return ( +
+
+ + +
+ +
+
+ {emailSent ? ( + /* ── Confirmation state ── */ +
+ +

+ Check your inbox +

+

+ We sent a sign-in link to {email}. Click it to continue. +

+ +
+ ) : ( + /* ── Sign-in form ── */ + <> +
+

+ {mode === 'signup' ? 'Create your account' : 'Welcome back'} +

+

+ {mode === 'signup' ? 'Track focus streaks, pomodoros, and more.' : 'Sign in to continue your focus sessions.'} +

+
+ + {/* OAuth buttons */} +
+ {([ + { provider: 'google' as const, label: 'Continue with Google', icon: }, + { provider: 'github' as const, label: 'Continue with GitHub', icon: }, + ]).map(({ provider, label, icon }) => ( + + ))} +
+ + {/* Divider */} +
+
+ + or continue with email + +
+
+ + {/* Email form */} +
void handleEmail(e)} className="flex flex-col gap-3"> +
+ + setEmail(e.target.value)} + placeholder="name@example.com" + required + className="w-full px-4 py-3 rounded-xl text-sm outline-none" + style={{ + background: 'var(--bg-secondary)', + border: `1px solid ${emailError ? 'var(--red, #e53e3e)' : 'var(--border)'}`, + color: 'var(--text-primary)', + }} + onFocus={e => { e.currentTarget.style.borderColor = 'var(--accent)' }} + onBlur={e => { e.currentTarget.style.borderColor = emailError ? 'var(--red, #e53e3e)' : 'var(--border)' }} + autoComplete="email" + /> + {emailError && ( +

{emailError}

+ )} +
+ + +
+ +

+ {mode === 'signup' ? ( + <> + Already have an account?{' '} + + + ) : ( + <> + Don't have an account?{' '} + + + )} +

+ + )} +
+
+
+ ) +} + +export default function LoginPage() { + return ( + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx index 1cc179f..04d565e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { createClient } from '@/lib/supabase/server' -import { LandingClient } from '@/components/landing/LandingClient' +import { HomeClient } from '@/components/home/HomeClient' export default async function HomePage() { const supabase = createClient() @@ -24,12 +24,11 @@ export default async function HomePage() { .eq('running', true) .neq('session_mode', 'solo') .gt('last_active_at', ninetySecondsAgo) - if (countError) console.error('[home] sessions count query failed:', countError) const activeSessionCount = countError ? 0 : (count ?? 0) return (
- +
) } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 72ece6f..ac22ec9 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -3,7 +3,7 @@ import { PolicyPageLayout } from '@/components/ui/PolicyPageLayout' export const metadata: Metadata = { title: 'Privacy Policy', - description: 'Privacy Policy for PomodoroJam', + description: 'Privacy Policy for Bonfire', } export default function PrivacyPage() { @@ -15,7 +15,7 @@ export default function PrivacyPage() {

How we use it

-

Your data is used solely to provide the PomodoroJam service: displaying your profile, tracking your focus stats, and enabling real-time session sync. We do not sell your data or use it for advertising.

+

Your data is used solely to provide the Bonfire service: displaying your profile, tracking your focus stats, and enabling real-time session sync. We do not sell your data or use it for advertising.

Third parties

diff --git a/app/profile/[username]/page.tsx b/app/profile/[username]/page.tsx index 3ef7d4a..a955cc0 100644 --- a/app/profile/[username]/page.tsx +++ b/app/profile/[username]/page.tsx @@ -31,9 +31,9 @@ export async function generateMetadata({ params }: ProfilePageProps): Promise ) diff --git a/app/sitemap.ts b/app/sitemap.ts index e8a9f13..511b3bc 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,6 @@ import type { MetadataRoute } from 'next' -const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://pomodoro-jam.vercel.app' +const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://bonfirefocus.vercel.app' export default function sitemap(): MetadataRoute.Sitemap { return [ diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 06f3b4f..30883e0 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -3,7 +3,7 @@ import { PolicyPageLayout } from '@/components/ui/PolicyPageLayout' export const metadata: Metadata = { title: 'Terms of Service', - description: 'Terms of Service for PomodoroJam', + description: 'Terms of Service for Bonfire', } export default function TermsPage() { @@ -11,11 +11,11 @@ export default function TermsPage() {

Use of service

-

PomodoroJam is a free productivity tool. You may use it for personal or professional focus sessions. You agree not to abuse the service, create sessions for malicious purposes, or attempt to reverse-engineer the platform.

+

Bonfire is a free productivity tool. You may use it for personal or professional focus sessions. You agree not to abuse the service, create sessions for malicious purposes, or attempt to reverse-engineer the platform.

No warranty

-

PomodoroJam is provided "as is" without warranty of any kind. We make no guarantees about uptime or data retention. Session data may be deleted after 7 days of inactivity.

+

Bonfire is provided "as is" without warranty of any kind. We make no guarantees about uptime or data retention. Session data may be deleted after 7 days of inactivity.

Accounts

@@ -23,7 +23,7 @@ export default function TermsPage() {

Changes

-

We may update these terms at any time. Continued use of PomodoroJam after changes constitutes acceptance.

+

We may update these terms at any time. Continued use of Bonfire after changes constitutes acceptance.

) diff --git a/components/home/HomeClient.tsx b/components/home/HomeClient.tsx new file mode 100644 index 0000000..af29193 --- /dev/null +++ b/components/home/HomeClient.tsx @@ -0,0 +1,436 @@ +'use client' + +import { useState, useRef, useEffect, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import type { User } from '@supabase/supabase-js' +import { ChevronDown, Shuffle, Globe, Lock, Github, LogOut, UserCircle } from 'lucide-react' +import { Logo } from '@/components/ui/Logo' +import { ThemeToggle } from '@/components/ui/ThemeToggle' +import { Avatar } from '@/components/ui/Avatar' +import { ToastProvider, useToast } from '@/components/ui/Toast' +import { createClient } from '@/lib/supabase/client' +import { generateRoomName, generateAnonName } from '@/lib/roomName' + +interface HomeClientProps { + user: User | null + profileUsername: string | null + activeSessionCount: number +} + +const GoogleIcon = () => ( + + + + + + +) + +function HomeContent({ user, profileUsername, activeSessionCount }: HomeClientProps) { + const router = useRouter() + const { toast } = useToast() + const supabase = useMemo(() => createClient(), []) + + const [roomName, setRoomName] = useState('') + const [guestName, setGuestName] = useState('') + const [isRoomPublic, setIsRoomPublic] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [isSigningIn, setIsSigningIn] = useState(false) + const [showUserMenu, setShowUserMenu] = useState(false) + const [showSignInMenu, setShowSignInMenu] = useState(false) + const [btnHovered, setBtnHovered] = useState(false) + const [btnPressed, setBtnPressed] = useState(false) + const roomNameInputRef = useRef(null) + const startBtnRef = useRef(null) + const menuRef = useRef(null) + const signInRef = useRef(null) + + function fireRipple(e: React.MouseEvent) { + const btn = startBtnRef.current + if (!btn) return + const rect = btn.getBoundingClientRect() + const size = Math.max(rect.width, rect.height) + const x = e.clientX - rect.left - size / 2 + const y = e.clientY - rect.top - size / 2 + const el = document.createElement('span') + el.style.cssText = `position:absolute;width:${size}px;height:${size}px;left:${x}px;top:${y}px;border-radius:50%;background:rgba(255,255,255,0.22);transform:scale(0);opacity:1;animation:btn-ripple 0.55s ease-out forwards;pointer-events:none;` + btn.appendChild(el) + el.addEventListener('animationend', () => el.remove()) + } + + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get('welcome') === '1') { + toast('Signed in successfully!', 'success', 4000) + const url = new URL(window.location.href) + url.searchParams.delete('welcome') + window.history.replaceState({}, '', url.toString()) + } + }, [toast]) + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowUserMenu(false) + if (signInRef.current && !signInRef.current.contains(e.target as Node)) setShowSignInMenu(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const handleCreate = async () => { + if (isCreating) return + setIsCreating(true) + try { + const finalRoomName = roomName.trim() || generateRoomName() + const finalGuestName = !user ? (guestName.trim() || generateAnonName()) : null + if (finalRoomName !== roomName) setRoomName(finalRoomName) + const res = await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: finalRoomName, is_public: isRoomPublic, display_name: finalGuestName }), + }) + if (!res.ok) throw new Error('Failed') + const { id } = await res.json() as { id: string } + if (finalGuestName) localStorage.setItem(`pomodoro_nick_${id}`, finalGuestName) + localStorage.setItem(`pomodoro_host_${id}`, '1') + const doc = document as Document & { startViewTransition?: (cb: () => void) => void } + if (doc.startViewTransition) { + doc.startViewTransition(() => router.push(`/session/${id}`)) + } else { + router.push(`/session/${id}`) + } + } catch { + toast('Could not create room. Please try again.', 'error') + setIsCreating(false) + } + } + + const handleSignIn = async (provider: 'github' | 'google') => { + setIsSigningIn(true) + setShowSignInMenu(false) + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: `${window.location.origin}/auth/callback?next=/` }, + }) + if (error) throw error + } catch { + setIsSigningIn(false) + toast('Sign-in failed. Please try again.', 'error') + } + } + + const handleSignOut = async () => { + try { await supabase.auth.signOut() } catch { /* non-critical */ } + router.refresh() + } + + return ( +
+ {/* Header */} +
+ + +
+ + Explore + + + {user && profileUsername && ( + + Profile + + )} + + {user ? ( +
+ + + {showUserMenu && ( +
+
+

+ {typeof user.user_metadata?.full_name === 'string' ? user.user_metadata.full_name : 'User'} +

+

{user.email}

+
+
+ {[ + ...(profileUsername ? [{ + icon: UserCircle, + label: 'View Profile', + action: () => { setShowUserMenu(false); router.push(`/profile/${profileUsername}`) }, + }] : []), + { + icon: LogOut, + label: 'Sign out', + action: () => { setShowUserMenu(false); void handleSignOut() }, + }, + ].map(({ icon: Icon, label, action }) => ( + + ))} +
+
+ )} +
+ ) : ( +
+ + + {showSignInMenu && ( +
+
+ {[ + { icon: , label: 'GitHub', provider: 'github' as const }, + { icon: , label: 'Google', provider: 'google' as const }, + ].map(({ icon, label, provider }) => ( + + ))} +
+
+ )} +
+ )} + + +
+
+ + {/* Main: create room form centred */} +
+
+
+

+ Start a focus room +

+

+ Solo or with a team. Share the link and focus in sync. +

+
+ + {/* Room name */} +
+ +
+ setRoomName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }} + maxLength={100} + placeholder="Enter your room name" + className="w-full pl-4 pr-11 py-3 rounded-xl text-sm outline-none" + style={{ + background: 'var(--bg-secondary)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={e => { e.currentTarget.style.borderColor = 'var(--accent)' }} + onBlur={e => { e.currentTarget.style.borderColor = 'var(--border)' }} + autoComplete="off" + autoFocus + /> + +
+
+ + {/* Your name (anonymous users only) */} + {!user && ( +
+ + setGuestName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }} + maxLength={40} + placeholder="Enter your name" + className="w-full px-4 py-3 rounded-xl text-sm outline-none" + style={{ + background: 'var(--bg-secondary)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + onFocus={e => { e.currentTarget.style.borderColor = 'var(--accent)' }} + onBlur={e => { e.currentTarget.style.borderColor = 'var(--border)' }} + autoComplete="off" + /> +
+ )} + + {/* Public / Private toggle */} + + + {/* CTA */} + + + {/* Live rooms link */} + + 0 ? 'var(--green)' : 'var(--text-muted)' }} + /> + {activeSessionCount > 0 + ? `${activeSessionCount} room${activeSessionCount !== 1 ? 's' : ''} live now. Join one →` + : 'No live rooms yet. Start the first one.'} + +
+
+
+ ) +} + +export function HomeClient(props: HomeClientProps) { + return ( + + + + ) +} diff --git a/components/landing/LandingClient.tsx b/components/landing/LandingClient.tsx index 4786d1f..5eb20c0 100644 --- a/components/landing/LandingClient.tsx +++ b/components/landing/LandingClient.tsx @@ -525,7 +525,7 @@ function LandingContent({ user, profileUsername, activeSessionCount }: LandingCl if (e.key === 'Escape') setShowCreateModal(false) }} maxLength={100} - placeholder="e.g. focused-panda-342" + placeholder="e.g. focused panda 342" className="w-full pl-4 pr-11 py-3 rounded-xl text-sm outline-none" style={{ background: 'var(--bg-secondary)', @@ -619,10 +619,10 @@ function LandingContent({ user, profileUsername, activeSessionCount }: LandingCl
Privacy Terms - © {new Date().getFullYear()} PomodoroJam + © {new Date().getFullYear()} Bonfire
{crown && (
- 👑 + H
)} diff --git a/components/session/AmbientPlayer.tsx b/components/session/AmbientPlayer.tsx index 94198ab..73e0a9d 100644 --- a/components/session/AmbientPlayer.tsx +++ b/components/session/AmbientPlayer.tsx @@ -1,18 +1,40 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Volume2, VolumeX } from 'lucide-react' +import { Volume2, VolumeX, ChevronDown } from 'lucide-react' import { AmbientPlayer as Player, AMBIENT_SOUNDS, type AmbientType } from '@/lib/ambient' +const NOISE_COLORS: Record = { + brown: '#8B5E3C', + pink: '#D86BAD', + white: '#A8A8A2', + rain: '#5BA8D4', +} + interface AmbientPlayerProps { onActiveChange?: (active: boolean) => void + /** compact=true renders a single select dropdown instead of the button grid */ + compact?: boolean } -export function AmbientPlayer({ onActiveChange }: AmbientPlayerProps) { +export function AmbientPlayer({ onActiveChange, compact = false }: AmbientPlayerProps) { const playerRef = useRef(null) const [active, setActive] = useState(null) const [volume, setVolume] = useState(0.12) const [muted, setMuted] = useState(false) + const [showPicker, setShowPicker] = useState(false) + const pickerRef = useRef(null) + + useEffect(() => { + if (!showPicker) return + function handleClick(e: MouseEvent) { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowPicker(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [showPicker]) useEffect(() => { const savedType = localStorage.getItem('pomodoro_ambient_type') as AmbientType | null @@ -56,17 +78,89 @@ export function AmbientPlayer({ onActiveChange }: AmbientPlayerProps) { playerRef.current?.setVolume(next ? 0 : volume) } + // Compact mode: custom colored dropdown for inline use (session bottom bar) + if (compact) { + const activeSound = active ? AMBIENT_SOUNDS.find(s => s.type === active) : null + return ( +
+ + + {showPicker && ( +
+ + {AMBIENT_SOUNDS.map(({ type, label }) => ( + + ))} +
+ )} +
+ ) + } + return (
- {AMBIENT_SOUNDS.map(({ type, emoji, label }) => ( + {AMBIENT_SOUNDS.map(({ type, label }) => ( ))} diff --git a/components/session/BonfireScene.tsx b/components/session/BonfireScene.tsx new file mode 100644 index 0000000..c7c9fe2 --- /dev/null +++ b/components/session/BonfireScene.tsx @@ -0,0 +1,580 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import * as THREE from 'three' + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface BonfireSceneProps { + targetIntensity: number + isSurging: boolean + focusCount: number + participantCount: number + mode: 'focus' | 'short' | 'long' +} + +// ─── Easing ─────────────────────────────────────────────────────────────────── + +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3) + +// ─── Animation constants ────────────────────────────────────────────────────── + +const FLOAT_DURATION = 3.5 // seconds per float cycle +const FLOAT_RANGE = 0.09 // ±0.09 world units ≈ ±9px at camera dist 3.2 +const PULSE_DURATION = 3.0 +const PULSE_RANGE = 0.025 // ±2.5% scale +const BASE_OPACITY = 0.90 +const OPACITY_RANGE = 0.04 + +// ─── Log pile configs (deterministic — no Math.random at render) ────────────── + +const LOG_CONFIGS = [ + { x: 0.00, y: 0.00, z: 0.00, rotY: 0.30, rotZ: 0.12 }, + { x: 0.00, y: 0.00, z: 0.05, rotY: -0.65, rotZ: -0.10 }, + { x: -0.08, y: 0.12, z: 0.02, rotY: 0.20, rotZ: 0.08 }, + { x: 0.09, y: 0.12, z: -0.02, rotY: -0.40, rotZ: -0.07 }, + { x: -0.05, y: 0.24, z: 0.03, rotY: 0.50, rotZ: 0.10 }, + { x: 0.06, y: 0.24, z: -0.03, rotY: -0.25, rotZ: -0.09 }, + { x: -0.03, y: 0.36, z: 0.01, rotY: 0.15, rotZ: 0.06 }, + { x: 0.04, y: 0.36, z: -0.01, rotY: -0.55, rotZ: -0.08 }, +] + +// ─── Teardrop flame configs ─────────────────────────────────────────────────── +// Five flames: center dominant, two mid, two smaller behind. +// Phase offsets ensure no two flames move/pulse/flicker in sync. + +interface FlameConfig { + x: number; baseY: number; z: number; baseScale: number + floatPhase: number; pulsePhase: number; opacityPhase: number + minIntensity: number // only visible above this threshold — flames unlock progressively +} + +// Timer start (~0.28 intensity): only center flame, small. +// Mid flames unlock at 0.35, back flames at 0.58 (via pomodoro surges / participants). +const FLAME_CONFIGS: FlameConfig[] = [ + { x: 0.00, baseY: 0.12, z: 0.02, baseScale: 0.58, floatPhase: 0.00, pulsePhase: 0.00, opacityPhase: 0.00, minIntensity: 0.05 }, + { x: -0.20, baseY: 0.06, z: 0.00, baseScale: 0.44, floatPhase: 0.85, pulsePhase: 1.40, opacityPhase: 1.20, minIntensity: 0.35 }, + { x: 0.20, baseY: 0.06, z: 0.00, baseScale: 0.44, floatPhase: 1.60, pulsePhase: 0.70, opacityPhase: 2.10, minIntensity: 0.35 }, + { x: -0.10, baseY: 0.01, z: -0.07, baseScale: 0.33, floatPhase: 2.30, pulsePhase: 2.00, opacityPhase: 0.80, minIntensity: 0.58 }, + { x: 0.10, baseY: 0.01, z: -0.07, baseScale: 0.33, floatPhase: 3.05, pulsePhase: 2.70, opacityPhase: 1.60, minIntensity: 0.58 }, +] + +// ─── GLSL shaders ───────────────────────────────────────────────────────────── + +// Teardrop flame: UV gradient yellow(base) → orange → red(tip) + left-edge gloss +const FLAME_VS = /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +` +const FLAME_FS = /* glsl */` + varying vec2 vUv; + uniform float uOpacity; + + void main() { + // v=0 → hot yellow base, v=1 → cool crimson tip + vec3 yellow = vec3(1.00, 0.87, 0.08); + vec3 orange = vec3(1.00, 0.50, 0.05); + vec3 red = vec3(0.82, 0.16, 0.02); + + vec3 color = vUv.y < 0.5 + ? mix(yellow, orange, vUv.y * 2.0) + : mix(orange, red, (vUv.y - 0.5) * 2.0); + + // Left-edge gloss — 3D depth illusion, light source from left + float gloss = smoothstep(0.05, 0.28, vUv.x) * (1.0 - smoothstep(0.28, 0.52, vUv.x)); + gloss *= (1.0 - vUv.y * 0.65); + color = mix(color, vec3(1.0, 0.97, 0.88), gloss * 0.32); + + gl_FragColor = vec4(color, uOpacity); + } +` + +// Shared UV-pass vertex for ground glow + completion glow +const GLOW_VS = /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +` + +// Ground warm glow (amber radial) +const GLOW_FS = /* glsl */` + varying vec2 vUv; + uniform float uIntensity; + void main() { + float dist = length(vUv - 0.5) * 2.0; + float glow = (1.0 - smoothstep(0.0, 1.0, dist)) * uIntensity; + vec3 color = mix(vec3(0.6, 0.15, 0.0), vec3(1.0, 0.45, 0.05), glow); + gl_FragColor = vec4(color, glow * 0.75); + } +` + +// Completion pulse: gold radial burst +const COMPLETION_FS = /* glsl */` + varying vec2 vUv; + uniform float uOpacity; + void main() { + float dist = length(vUv - 0.5) * 2.0; + float ring = 1.0 - smoothstep(0.0, 1.0, dist); + gl_FragColor = vec4(1.0, 0.84, 0.0, ring * uOpacity); // #FFD700 gold + } +` + +// Ember point sprites (same round-particle shader as before) +const EMBER_VS = /* glsl */` + attribute float aSize; + attribute float aAlpha; + attribute vec3 aColor; + varying vec3 vColor; + varying float vAlpha; + void main() { + vColor = aColor; vAlpha = aAlpha; + vec4 mvp = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = aSize * (300.0 / -mvp.z); + gl_Position = projectionMatrix * mvp; + } +` +const EMBER_FS = /* glsl */` + varying vec3 vColor; + varying float vAlpha; + void main() { + vec2 uv = gl_PointCoord - 0.5; + float dist = length(uv); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, dist) * vAlpha; + gl_FragColor = vec4(vColor, alpha); + } +` + +// ─── Teardrop shape geometry factory ───────────────────────────────────────── +// Two cubic beziers — one per side — produce a clean symmetric teardrop. +// Tip at top (0, 0.56), rounded base (0, −0.36). +// THREE.ShapeGeometry auto-maps UVs to bounding box: +// v=0 → bottom (yellow/base), v=1 → top (red/tip) ✓ + +function createTeardropShape(): THREE.Shape { + const s = new THREE.Shape() + s.moveTo(0, 0.56) + s.bezierCurveTo( 0.30, 0.36, 0.32, -0.10, 0.0, -0.36) + s.bezierCurveTo(-0.32, -0.10, -0.30, 0.36, 0.0, 0.56) + return s +} + +// ─── SingleFlame ────────────────────────────────────────────────────────────── +// Each flame owns its ShaderMaterial (separate uOpacity uniform per instance). +// Shares geometry with siblings. All animation via useFrame ref mutation — no setState. + +function SingleFlame({ + config, + intensityRef, + isSurgingRef, + modeRef, + geometry, +}: { + config: FlameConfig + intensityRef: React.MutableRefObject + isSurgingRef: React.MutableRefObject + modeRef: React.MutableRefObject<'focus' | 'short' | 'long'> + geometry: THREE.ShapeGeometry +}) { + const meshRef = useRef(null) + const timeRef = useRef(Math.random() * 100) // random start so flames don't sync on mount + const surgeRef = useRef(0) + + const material = useMemo(() => new THREE.ShaderMaterial({ + vertexShader: FLAME_VS, + fragmentShader: FLAME_FS, + uniforms: { uOpacity: { value: 0.9 } }, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }), []) + + useEffect(() => () => material.dispose(), [material]) + + useFrame((_, delta) => { + const mesh = meshRef.current + if (!mesh) return + + timeRef.current += delta + const t = timeRef.current + const intensity = intensityRef.current + + mesh.visible = intensity > config.minIntensity + + // Slow float + pulse during breaks — fire breathes lazily at rest + const m = modeRef.current + const floatDuration = m === 'long' ? 8.0 : m === 'short' ? 5.5 : FLOAT_DURATION + const pulseDuration = m === 'long' ? 7.0 : m === 'short' ? 5.0 : PULSE_DURATION + + // Vertical float — sine wave, staggered phase per flame + const floatY = Math.sin(2 * Math.PI * (t + config.floatPhase) / floatDuration) * FLOAT_RANGE + + // Scale pulse — sine wave, different phase + const pulseScale = 1.0 + Math.sin(2 * Math.PI * (t + config.pulsePhase) / pulseDuration) * PULSE_RANGE + + // Completion surge — fast ramp up, slow decay + if (isSurgingRef.current) { + surgeRef.current = Math.min(surgeRef.current + delta * 5, 1) + } else { + surgeRef.current = Math.max(surgeRef.current - delta * 1.0, 0) + } + const surgeBoost = 1.0 + easeOutCubic(surgeRef.current) * 0.10 + + // Opacity flicker — slower cycle, subtle range + const opacity = (BASE_OPACITY + Math.sin(2 * Math.PI * (t * 1.5 + config.opacityPhase) / 2.0) * OPACITY_RANGE) + * Math.min(intensity * 2.5, 1) + + const finalScale = config.baseScale * pulseScale * surgeBoost * (0.25 + intensity * 0.75) + + mesh.position.set(config.x, config.baseY + floatY, config.z) + mesh.scale.set(finalScale, finalScale * 1.15, 1) // 15% taller than wide — more flame-like + mesh.rotation.x = -0.28 // tilt toward camera (~16°) + + material.uniforms.uOpacity.value = Math.max(0, opacity) + }) + + return +} + +// ─── TeardropFlames ─────────────────────────────────────────────────────────── +// Shared geometry, 5 independent mesh instances. + +function TeardropFlames({ + intensityRef, + isSurgingRef, + modeRef, +}: { + intensityRef: React.MutableRefObject + isSurgingRef: React.MutableRefObject + modeRef: React.MutableRefObject<'focus' | 'short' | 'long'> +}) { + const geometry = useMemo(() => new THREE.ShapeGeometry(createTeardropShape(), 24), []) + useEffect(() => () => geometry.dispose(), [geometry]) + + return ( + + {FLAME_CONFIGS.map((cfg, i) => ( + + ))} + + ) +} + +// ─── Embers ─────────────────────────────────────────────────────────────────── +// 5 max. Slow upward drift in narrow column. Soft gold. Barely visible — ambient only. + +interface EmberData { + positions: Float32Array; velocities: Float32Array + colors: Float32Array; sizes: Float32Array + alphas: Float32Array; lifetimes: Float32Array; maxLifetimes: Float32Array +} + +function Embers({ intensityRef }: { intensityRef: React.MutableRefObject }) { + const MAX = 5 + const ptsRef = useRef(null) + const smoothRef = useRef(0) + + const { geometry, material, ed } = useMemo(() => { + const positions = new Float32Array(MAX * 3) + const velocities = new Float32Array(MAX * 3) + const colors = new Float32Array(MAX * 3) + const sizes = new Float32Array(MAX) + const alphas = new Float32Array(MAX) + const lifetimes = new Float32Array(MAX) + const maxLifetimes = new Float32Array(MAX) + + for (let i = 0; i < MAX; i++) { + maxLifetimes[i] = 2.0 + Math.random() * 1.0 + lifetimes[i] = Math.random() * maxLifetimes[i] // staggered initial spawn + positions[i * 3 + 1] = -20 + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geo.setAttribute('aColor', new THREE.BufferAttribute(colors, 3)) + geo.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)) + geo.setAttribute('aAlpha', new THREE.BufferAttribute(alphas, 1)) + + const mat = new THREE.ShaderMaterial({ + vertexShader: EMBER_VS, + fragmentShader: EMBER_FS, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }) + + const ed: EmberData = { positions, velocities, colors, sizes, alphas, lifetimes, maxLifetimes } + return { geometry: geo, material: mat, ed } + }, []) + + useEffect(() => () => { geometry.dispose(); material.dispose() }, [geometry, material]) + + useFrame((_, delta) => { + if (!ptsRef.current) return + smoothRef.current += (intensityRef.current - smoothRef.current) * Math.min(delta * 1.5, 1) + const intensity = smoothRef.current + + for (let i = 0; i < MAX; i++) { + ed.lifetimes[i] -= delta + + if (ed.lifetimes[i] <= 0) { + if (intensity < 0.12) { + ed.positions[i * 3 + 1] = -20; ed.alphas[i] = 0; ed.sizes[i] = 0 + continue + } + // Spawn in tight column above flame tips + ed.positions[i * 3] = (Math.random() - 0.5) * 0.22 + ed.positions[i * 3 + 1] = 0.50 + Math.random() * 0.28 + ed.positions[i * 3 + 2] = (Math.random() - 0.5) * 0.06 + + // Slow upward, near-zero horizontal drift + ed.velocities[i * 3] = (Math.random() - 0.5) * 0.03 + ed.velocities[i * 3 + 1] = 0.16 + Math.random() * 0.10 // ~16–26px/sec + ed.velocities[i * 3 + 2] = 0 + + ed.maxLifetimes[i] = 2.0 + Math.random() * 1.0 + ed.lifetimes[i] = ed.maxLifetimes[i] + continue + } + + ed.positions[i * 3] += ed.velocities[i * 3] * delta + ed.positions[i * 3 + 1] += ed.velocities[i * 3 + 1] * delta + + const ratio = ed.lifetimes[i] / ed.maxLifetimes[i] + + // Soft gold, slight white tint when freshly spawned + ed.colors[i * 3] = 1.0 + ed.colors[i * 3 + 1] = 0.65 + ratio * 0.25 + ed.colors[i * 3 + 2] = ratio * 0.12 + + const fadeIn = Math.min(1.0, (ed.maxLifetimes[i] - ed.lifetimes[i]) / 0.25) + ed.alphas[i] = fadeIn * ratio * 0.70 + ed.sizes[i] = 0.04 + ratio * 0.025 + } + + ;(geometry.getAttribute('position') as THREE.BufferAttribute).needsUpdate = true + ;(geometry.getAttribute('aColor') as THREE.BufferAttribute).needsUpdate = true + ;(geometry.getAttribute('aSize') as THREE.BufferAttribute).needsUpdate = true + ;(geometry.getAttribute('aAlpha') as THREE.BufferAttribute).needsUpdate = true + }) + + return +} + +// ─── CompletionGlow ─────────────────────────────────────────────────────────── +// Gold radial burst that expands outward when a pomodoro completes (isSurging rises). +// Triggered on rising edge of isSurgingRef; plays once over 1.5s then resets. + +function CompletionGlow({ isSurgingRef }: { isSurgingRef: React.MutableRefObject }) { + const meshRef = useRef(null) + const prevSurge = useRef(false) + const animTime = useRef(-1) // −1 = inactive + const DURATION = 1.5 + + const material = useMemo(() => new THREE.ShaderMaterial({ + vertexShader: GLOW_VS, + fragmentShader: COMPLETION_FS, + uniforms: { uOpacity: { value: 0 } }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }), []) + + useEffect(() => () => material.dispose(), [material]) + + useFrame((_, delta) => { + const mesh = meshRef.current + if (!mesh) return + + // Rising-edge detection — start animation when isSurging flips true + const surging = isSurgingRef.current + if (surging && !prevSurge.current) animTime.current = 0 + prevSurge.current = surging + + if (animTime.current < 0) { + material.uniforms.uOpacity.value = 0 + return + } + + animTime.current += delta + const t = Math.min(animTime.current / DURATION, 1) + + if (t >= 1) { + animTime.current = -1 + material.uniforms.uOpacity.value = 0 + mesh.scale.setScalar(0.1) + return + } + + // Opacity: peak at 25% → fade out + const opacity = t < 0.25 + ? easeOutCubic(t / 0.25) * 0.38 + : (1 - easeOutCubic((t - 0.25) / 0.75)) * 0.38 + + // Radius grows 0.4 → 3.2 world units + const radius = 0.4 + easeOutCubic(t) * 2.8 + + material.uniforms.uOpacity.value = opacity + mesh.scale.set(radius, radius, 1) + }) + + return ( + + + + + ) +} + +// ─── LogPile ────────────────────────────────────────────────────────────────── + +function LogPile({ logCount }: { logCount: number }) { + const count = Math.min(Math.max(logCount, 2), 8) + return ( + + {LOG_CONFIGS.slice(0, count).map((cfg, i) => ( + + + + + ))} + + ) +} + +// ─── GroundGlow ─────────────────────────────────────────────────────────────── + +function GroundGlow({ intensityRef }: { intensityRef: React.MutableRefObject }) { + const smoothRef = useRef(0) + const material = useMemo(() => new THREE.ShaderMaterial({ + vertexShader: GLOW_VS, + fragmentShader: GLOW_FS, + uniforms: { uIntensity: { value: 0 } }, + transparent: true, depthWrite: false, + blending: THREE.AdditiveBlending, side: THREE.DoubleSide, + }), []) + useEffect(() => () => material.dispose(), [material]) + useFrame((_, delta) => { + smoothRef.current += (intensityRef.current - smoothRef.current) * Math.min(delta * 1.8, 1) + material.uniforms.uIntensity.value = smoothRef.current + }) + return ( + + + + + ) +} + +// ─── FireLight ──────────────────────────────────────────────────────────────── +// PointLight at fire heart, flickers with multi-frequency sine sum. Illuminates logs. + +function FireLight({ intensityRef }: { intensityRef: React.MutableRefObject }) { + const lightRef = useRef(null) + const smoothRef = useRef(0) + const timeRef = useRef(0) + + useFrame((_, delta) => { + if (!lightRef.current) return + timeRef.current += delta + smoothRef.current += (intensityRef.current - smoothRef.current) * Math.min(delta * 2, 1) + const flicker = + 1.0 + + Math.sin(timeRef.current * 7.3) * 0.06 + + Math.sin(timeRef.current * 13.7) * 0.04 + + Math.sin(timeRef.current * 3.1) * 0.03 + lightRef.current.intensity = smoothRef.current * 4.5 * flicker + lightRef.current.color.setRGB(1.0, 0.40 + smoothRef.current * 0.18, 0.05) + }) + + return +} + +// ─── CameraSetup ────────────────────────────────────────────────────────────── + +function CameraSetup({ modeRef }: { modeRef: React.MutableRefObject<'focus' | 'short' | 'long'> }) { + const { camera } = useThree() + useEffect(() => { + camera.position.set(0, 1.2, 3.2) + camera.lookAt(0, 0.3, 0) + }, [camera]) + useFrame((_, delta) => { + // Pull camera back during breaks — breathing room, fire feels smaller/calmer + const targetZ = modeRef.current === 'long' ? 4.2 : modeRef.current === 'short' ? 3.7 : 3.2 + camera.position.z += (targetZ - camera.position.z) * Math.min(delta * 0.6, 1) + camera.lookAt(0, 0.3, 0) + }) + return null +} + +// ─── BonfireScene (main export) ─────────────────────────────────────────────── +// Dynamic-imported with ssr:false from SessionProvider — never server-rendered. + +export function BonfireScene({ targetIntensity, isSurging, focusCount, mode }: BonfireSceneProps) { + const intensityRef = useRef(targetIntensity) + const isSurgingRef = useRef(isSurging) + const modeRef = useRef(mode) + const containerRef = useRef(null) + const [ready, setReady] = useState(false) + + useEffect(() => { intensityRef.current = targetIntensity }, [targetIntensity]) + useEffect(() => { isSurgingRef.current = isSurging }, [isSurging]) + useEffect(() => { modeRef.current = mode }, [mode]) + + // Wait for real pixel dimensions before mounting Canvas. + // r3f reads size via ResizeObserver; mounting at 0×0 means it never resizes correctly. + useEffect(() => { + const el = containerRef.current + if (!el) return + const tryReady = () => { + const { width, height } = el.getBoundingClientRect() + if (width > 0 && height > 0) setReady(true) + } + tryReady() + const ro = new ResizeObserver(tryReady) + ro.observe(el) + return () => ro.disconnect() + }, []) + + const logCount = Math.min(2 + focusCount, 8) + + return ( + + ) +} diff --git a/components/session/GuideModal.tsx b/components/session/GuideModal.tsx new file mode 100644 index 0000000..052aa2b --- /dev/null +++ b/components/session/GuideModal.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useEffect, useState } from 'react' +import { X, ChevronRight } from 'lucide-react' +import { AMBIENT_SOUNDS } from '@/lib/ambient' + +interface GuideModalProps { + onClose: () => void +} + +const shortcuts = [ + { key: 'Space', description: 'Play / Pause (host or jam mode)' }, + { key: '?', description: 'Open this guide' }, + { key: 'Esc', description: 'Close panel or overlay' }, +] + +function GuideSection({ + title, + isOpen, + onToggle, + children, +}: { + title: string + isOpen: boolean + onToggle: () => void + children: React.ReactNode +}) { + return ( +
+ + {isOpen && ( +
+ {children} +
+ )} +
+ ) +} + +function GuideContent({ openSection, setOpenSection }: { + openSection: string | null + setOpenSection: (id: string | null) => void +}) { + function toggle(id: string) { + setOpenSection(openSection === id ? null : id) + } + + return ( +
+ toggle('solo')}> +

Your own private focus room. The timer is fully under your control: start, pause, and skip whenever you need.

+

New participants cannot join while Solo mode is active. Switch to Jam or Host mode if you want to collaborate.

+
+ + toggle('jam')}> +

Collaborative focus. Anyone in the room can start, pause, or skip the timer. No hierarchy, full trust.

+

Best for study groups or pairs who want shared control. New participants can join via the invite link.

+
+ + toggle('host')}> +

You lead, others follow. Only the host can control the timer. Participants watch the same countdown in real time.

+

Ideal when one person is running a group session or Pomodoro sprint. Guests can still request timer changes and you approve or decline.

+
+ + toggle('settings')}> +

If you are a participant in Host mode, you can suggest timer changes without disrupting the session.

+
    +
  1. Open the settings panel (gear icon below the timer).
  2. +
  3. Adjust focus duration, break lengths, or rounds.
  4. +
  5. Tap Send Request. The host gets a card showing exactly what changed.
  6. +
  7. The host accepts or declines. You see the result instantly.
  8. +
+

Requests time out after 30 seconds if the host does not respond.

+
+ + toggle('noise')}> +

All sounds are generated locally with the Web Audio API. No files, no network requests.

+
+ {AMBIENT_SOUNDS.map(({ type, label, description }) => ( +
+ + {label} + + {description} +
+ ))} +
+
+ + toggle('shortcuts')}> +
+ {shortcuts.map(({ key, description }) => ( +
+
{description}
+
+ + {key} + +
+
+ ))} +
+
+
+ ) +} + +export function GuideModal({ onClose }: GuideModalProps) { + const [openSection, setOpenSection] = useState('solo') + + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') { e.stopPropagation(); onClose() } + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + + const header = (id: string) => ( +
+

+ Guide +

+ +
+ ) + + return ( + // Backdrop +
+ {/* Desktop — right sidebar */} + + + {/* Mobile — bottom sheet */} +
e.stopPropagation()} + > + {/* Drag handle */} +
+ {header('guide-title-mobile')} +
+ +
+
+
+ ) +} diff --git a/components/session/KeyboardShortcutsModal.tsx b/components/session/KeyboardShortcutsModal.tsx deleted file mode 100644 index 987dce2..0000000 --- a/components/session/KeyboardShortcutsModal.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client' - -import { useEffect } from 'react' - -interface KeyboardShortcutsModalProps { - onClose: () => void -} - -const shortcuts = [ - { key: 'Space', description: 'Play / Pause (host or jam mode)' }, - { key: '?', description: 'Open this help' }, - { key: 'Esc', description: 'Close panel or overlay' }, -] - -export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) { - useEffect(() => { - function handleKey(e: KeyboardEvent) { - if (e.key === 'Escape') { e.stopPropagation(); onClose() } - } - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [onClose]) - - return ( -
-
e.stopPropagation()} - > -

- Keyboard Shortcuts -

-
- {shortcuts.map(({ key, description }) => ( -
-
{description}
-
- - {key} - -
-
- ))} -
- -
-
- ) -} diff --git a/components/session/SessionProvider.tsx b/components/session/SessionProvider.tsx index d4406da..5cb938f 100644 --- a/components/session/SessionProvider.tsx +++ b/components/session/SessionProvider.tsx @@ -1,35 +1,61 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import Link from 'next/link' -import { Wifi, WifiOff, Zap, Users, Settings, ChevronDown, Music2 } from 'lucide-react' -import type { ActivityItem, Session, SettingsChangeRequest, TimerMode, TimerState } from '@/types' -import { useTimer } from '@/hooks/useTimer' -import { useSession } from '@/hooks/useSession' -import { computeProgress, sessionToTimerState } from '@/lib/timer' -import { generateUsername } from '@/lib/roomName' -import { SettingsPanel, type TimerDurations, type SessionSettings } from '@/components/session/SettingsPanel' -import { playCompleteSound, showNotification, requestNotificationPermission } from '@/lib/audio' -import { TimerRing } from '@/components/timer/TimerRing' -import { TimerDisplay } from '@/components/timer/TimerDisplay' -import { TimerControls } from '@/components/timer/TimerControls' -import { ModeSelector } from '@/components/timer/ModeSelector' -import { MissedEventsToast } from '@/components/session/MissedEventsToast' -import { ParticipantList } from '@/components/session/ParticipantList' -import { SharePanel } from '@/components/session/SharePanel' -import { ActivityFeed } from '@/components/session/ActivityFeed' -import { BreakOverlay } from '@/components/session/BreakOverlay' -import { GuestNicknamePrompt } from '@/components/session/GuestNicknamePrompt' -import { SettingsRequestCard } from '@/components/session/SettingsRequestCard' -import { AmbientPlayer } from '@/components/session/AmbientPlayer' -import { StatsTab } from '@/components/session/StatsTab' -import { ModeTipBubble } from '@/components/session/ModeTipBubble' -import { KeyboardShortcutsModal } from '@/components/session/KeyboardShortcutsModal' -import { ToastProvider, useToast } from '@/components/ui/Toast' -import { Logo } from '@/components/ui/Logo' -import { ThemeToggle } from '@/components/ui/ThemeToggle' -import { createClient } from '@/lib/supabase/client' -import { cn } from '@/lib/utils' +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { WifiOff, Users, Settings, UserCircle } from "lucide-react"; +import type { + ActivityItem, + Session, + SettingsChangeRequest, + TimerMode, + TimerState, +} from "@/types"; +import { useTimer } from "@/hooks/useTimer"; +import { useSession } from "@/hooks/useSession"; +import { useBonfireState } from "@/hooks/useBonfireState"; +import { sessionToTimerState } from "@/lib/timer"; +import { generateUsername } from "@/lib/roomName"; +import { + SettingsPanel, + type TimerDurations, + type SessionSettings, +} from "@/components/session/SettingsPanel"; +import { + playCompleteSound, + showNotification, + requestNotificationPermission, +} from "@/lib/audio"; +import { TimerDisplay } from "@/components/timer/TimerDisplay"; +import { TimerControls } from "@/components/timer/TimerControls"; +import { ModeSelector } from "@/components/timer/ModeSelector"; +import { MissedEventsToast } from "@/components/session/MissedEventsToast"; +import { ParticipantList } from "@/components/session/ParticipantList"; +import { SharePanel } from "@/components/session/SharePanel"; +import { ActivityFeed } from "@/components/session/ActivityFeed"; +import { BreakOverlay } from "@/components/session/BreakOverlay"; +import { GuestNicknamePrompt } from "@/components/session/GuestNicknamePrompt"; +import { SettingsRequestCard } from "@/components/session/SettingsRequestCard"; +import { AmbientPlayer } from "@/components/session/AmbientPlayer"; +import { StatsTab } from "@/components/session/StatsTab"; +import { ModeTipBubble } from "@/components/session/ModeTipBubble"; +import { GuideModal } from "@/components/session/GuideModal"; +import { ToastProvider, useToast } from "@/components/ui/Toast"; +import { Logo } from "@/components/ui/Logo"; +import { Avatar } from "@/components/ui/Avatar"; +import { createClient } from "@/lib/supabase/client"; +import { cn } from "@/lib/utils"; + +// Dynamic import with ssr:false — BonfireScene uses WebGL / Three.js which +// calls window/document APIs that don't exist on the server. Next.js will +// skip rendering it server-side and mount it only in the browser. +const BonfireScene = dynamic( + () => + import("@/components/session/BonfireScene").then((m) => ({ + default: m.BonfireScene, + })), + { ssr: false }, +); // Module-level pure function — no closure deps function toSecs(d: TimerDurations) { @@ -37,15 +63,16 @@ function toSecs(d: TimerDurations) { focus: d.focus * 60, short: d.short * 60, long: d.long * 60, - } + }; } interface SessionProviderProps { - session: Session - userId: string | null - isHost: boolean - username?: string | null - avatarUrl?: string | null + session: Session; + userId: string | null; + isHost: boolean; + username?: string | null; + avatarUrl?: string | null; + profileUsername?: string | null; } function SessionContent({ @@ -54,13 +81,18 @@ function SessionContent({ isHost: isHostProp, username, avatarUrl, + profileUsername, }: SessionProviderProps) { - const [isHost, setIsHost] = useState(isHostProp) - const [activeTab, setActiveTab] = useState<'timer' | 'tasks' | 'stats'>('timer') - const [sessionMode, setSessionMode] = useState<'host' | 'jam' | 'solo'>(session.session_mode ?? 'host') - const [isPublic, setIsPublic] = useState(session.is_public ?? true) - const [showBreakOverlay, setShowBreakOverlay] = useState(false) - const [showSharePanel, setShowSharePanel] = useState(false) + const [isHost, setIsHost] = useState(isHostProp); + const [activeTab, setActiveTab] = useState<"timer" | "tasks" | "stats">( + "timer", + ); + const [sessionMode, setSessionMode] = useState<"host" | "jam" | "solo">( + session.session_mode ?? "host", + ); + const [isPublic, setIsPublic] = useState(session.is_public ?? true); + const [showBreakOverlay, setShowBreakOverlay] = useState(false); + const [showSharePanel, setShowSharePanel] = useState(false); const [sessionSettings, setSessionSettings] = useState({ durations: { focus: session.settings?.focus ?? 25, @@ -71,946 +103,1266 @@ function SessionContent({ allowGuestShare: session.settings?.allowGuestShare ?? true, autoStartBreaks: session.settings?.autoStartBreaks ?? false, autoStartPomodoros: session.settings?.autoStartPomodoros ?? false, - }) - const initialPomosDone = session.pomos_done ?? 0 - const focusCountRef = useRef(initialPomosDone) - const [focusCount, setFocusCount] = useState(initialPomosDone) - const [todayCount, setTodayCount] = useState(null) - const [activities, setActivities] = useState([]) - const sessionLogRef = useRef([]) - const totalLogCountRef = useRef(0) - const tabHiddenAtRef = useRef(null) - const logCountAtHideRef = useRef(0) - const missedEventsTimerRef = useRef | null>(null) - const [missedEvents, setMissedEvents] = useState([]) + }); + const initialPomosDone = session.pomos_done ?? 0; + const focusCountRef = useRef(initialPomosDone); + const [focusCount, setFocusCount] = useState(initialPomosDone); + const [todayCount, setTodayCount] = useState(null); + const [activities, setActivities] = useState([]); + const sessionLogRef = useRef([]); + const totalLogCountRef = useRef(0); + const tabHiddenAtRef = useRef(null); + const logCountAtHideRef = useRef(0); + const missedEventsTimerRef = useRef | null>( + null, + ); + const [missedEvents, setMissedEvents] = useState([]); // localUsername: for guests this starts null and is set when they save a nickname - const [localUsername, setLocalUsername] = useState(username ?? null) - const [showNicknamePrompt, setShowNicknamePrompt] = useState(false) - const [nicknameReady, setNicknameReady] = useState(!!userId) - const [showSettings, setShowSettings] = useState(false) - const [showWatcherSettings, setShowWatcherSettings] = useState(false) - const [showAmbient, setShowAmbient] = useState(false) - const [ambientActive, setAmbientActive] = useState(false) - const [pendingRequest, setPendingRequest] = useState(null) - const [pendingSettingsRequest, setPendingSettingsRequest] = useState(false) - const [showShortcutsModal, setShowShortcutsModal] = useState(false) - const pendingRequestRef = useRef(null) - useEffect(() => { pendingRequestRef.current = pendingRequest }, [pendingRequest]) - const [modeTipDismissed, setModeTipDismissed] = useState(false) - const sharePanelRef = useRef(null) - const settingsPanelRef = useRef(null) - const settingsPanelDesktopRef = useRef(null) - const watcherSettingsPanelRef = useRef(null) - const watcherSettingsPanelDesktopRef = useRef(null) - const hasRequestedNotifRef = useRef(false) - const supabase = useMemo(() => createClient(), []) - const { toast } = useToast() + const [localUsername, setLocalUsername] = useState( + username ?? null, + ); + const [showNicknamePrompt, setShowNicknamePrompt] = useState(false); + const [nicknameReady, setNicknameReady] = useState(!!userId); + const [showSettings, setShowSettings] = useState(false); + const [showWatcherSettings, setShowWatcherSettings] = useState(false); + const [showAmbient, setShowAmbient] = useState(false); + const [ambientActive, setAmbientActive] = useState(false); + const [pendingRequest, setPendingRequest] = + useState(null); + const [pendingSettingsRequest, setPendingSettingsRequest] = useState(false); + const [showGuideModal, setShowGuideModal] = useState(false); + const pendingRequestRef = useRef(null); + useEffect(() => { + pendingRequestRef.current = pendingRequest; + }, [pendingRequest]); + const [modeTipDismissed, setModeTipDismissed] = useState(false); + const sharePanelRef = useRef(null); + const settingsPanelRef = useRef(null); + const settingsPanelDesktopRef = useRef(null); + const watcherSettingsPanelRef = useRef(null); + const watcherSettingsPanelDesktopRef = useRef(null); + const hasRequestedNotifRef = useRef(false); + const supabase = useMemo(() => createClient(), []); + const { toast } = useToast(); // Fetch today's completed pomodoro count for the counter badge useEffect(() => { - if (!userId) return - const today = new Date() - today.setHours(0, 0, 0, 0) + if (!userId) return; + const today = new Date(); + today.setHours(0, 0, 0, 0); supabase - .from('pomodoro_logs') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('completed_at', today.toISOString()) - .then(({ count }) => { setTodayCount(count ?? 0) }) - }, [userId, supabase]) + .from("pomodoro_logs") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("completed_at", today.toISOString()) + .then(({ count }) => { + setTodayCount(count ?? 0); + }); + }, [userId, supabase]); // Guest host detection via localStorage useEffect(() => { if (!isHostProp) { - const guestOwned = localStorage.getItem(`pomodoro_host_${session.id}`) === '1' - if (guestOwned) setIsHost(true) + const guestOwned = + localStorage.getItem(`pomodoro_host_${session.id}`) === "1"; + if (guestOwned) setIsHost(true); } - }, [session.id, isHostProp]) + }, [session.id, isHostProp]); // Restore or prompt for nickname (guests only) useEffect(() => { - if (userId) return // auth users have a username already - const stored = localStorage.getItem(`pomodoro_nick_${session.id}`) + if (userId) return; // auth users have a username already + const stored = localStorage.getItem(`pomodoro_nick_${session.id}`); if (stored) { - setLocalUsername(stored) - setNicknameReady(true) + setLocalUsername(stored); + setNicknameReady(true); } else { - setShowNicknamePrompt(true) + setShowNicknamePrompt(true); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // intentionally run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // intentionally run once on mount - const canControl = isHost || sessionMode === 'jam' + const canControl = isHost || sessionMode === "jam"; - const modeRef = useRef('focus') + const modeRef = useRef("focus"); - const sessionSettingsRef = useRef(sessionSettings) - useEffect(() => { sessionSettingsRef.current = sessionSettings }, [sessionSettings]) + const sessionSettingsRef = useRef(sessionSettings); + useEffect(() => { + sessionSettingsRef.current = sessionSettings; + }, [sessionSettings]); - const canControlRef = useRef(canControl) - useEffect(() => { canControlRef.current = canControl }, [canControl]) + const canControlRef = useRef(canControl); + useEffect(() => { + canControlRef.current = canControl; + }, [canControl]); // Refs for functions defined later — avoids temporal dead zone in handleExpire - const broadcastTimerStateRef = useRef<((state: TimerState) => void) | null>(null) - const skipAndStartRef = useRef<((nextMode: TimerMode, durations?: Record) => TimerState) | null>(null) + const broadcastTimerStateRef = useRef<((state: TimerState) => void) | null>( + null, + ); + const skipAndStartRef = useRef< + | (( + nextMode: TimerMode, + durations?: Record, + ) => TimerState) + | null + >(null); // Serialized write queue — prevents concurrent timer writes from racing each other - const writeQueueRef = useRef>(Promise.resolve()) - const enqueueSessionUpdate = useCallback((patch: Record) => { - writeQueueRef.current = writeQueueRef.current - .catch(() => undefined) - .then(async () => { - const { error } = await supabase.from('sessions').update(patch).eq('id', session.id) - if (error) console.error('[session] DB write failed:', error) - }) - }, [supabase, session.id]) + const writeQueueRef = useRef>(Promise.resolve()); + const enqueueSessionUpdate = useCallback( + (patch: Record) => { + writeQueueRef.current = writeQueueRef.current + .catch(() => undefined) + .then(async () => { + const { error } = await supabase + .from("sessions") + .update(patch) + .eq("id", session.id); + if (error) console.error("[session] DB write failed:", error); + }); + }, + [supabase, session.id], + ); const handleExpire = useCallback(() => { - playCompleteSound() - showNotification('PomodoroJam', 'Your room has ended! Time for a break.') - const currentMode = modeRef.current - const settings = sessionSettingsRef.current - const durations = toSecs(settings.durations) + playCompleteSound(); + showNotification("Bonfire", "Your room has ended! Time for a break."); + const currentMode = modeRef.current; + const settings = sessionSettingsRef.current; + const durations = toSecs(settings.durations); // Log completed pomodoro for authenticated users (focus mode only) - if (userId && currentMode === 'focus') { - const minutes = Math.round(settings.durations.focus) - supabase.rpc('increment_profile_stats', { - p_user_id: userId, - p_minutes: minutes, - p_session_id: session.id, - }).then(({ error }) => { - if (error) console.error('[handleExpire] Failed to log pomodoro:', error) - }) + if (userId && currentMode === "focus") { + const minutes = Math.round(settings.durations.focus); + supabase + .rpc("increment_profile_stats", { + p_user_id: userId, + p_minutes: minutes, + p_session_id: session.id, + }) + .then(({ error }) => { + if (error) + console.error("[handleExpire] Failed to log pomodoro:", error); + }); } - if (currentMode === 'focus' && settings.autoStartBreaks) { - focusCountRef.current += 1 - setFocusCount(focusCountRef.current) - setTodayCount(prev => (prev ?? 0) + 1) - const nextMode = focusCountRef.current % settings.rounds === 0 ? 'long' : 'short' - const newState = skipAndStartRef.current?.(nextMode, durations) - if (!newState) return + if (currentMode === "focus" && settings.autoStartBreaks) { + focusCountRef.current += 1; + setFocusCount(focusCountRef.current); + setTodayCount((prev) => (prev ?? 0) + 1); + const nextMode = + focusCountRef.current % settings.rounds === 0 ? "long" : "short"; + const newState = skipAndStartRef.current?.(nextMode, durations); + if (!newState) return; if (canControlRef.current) { - broadcastTimerStateRef.current?.(newState) - enqueueSessionUpdate({ running: true, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode, pomos_done: focusCountRef.current }) + broadcastTimerStateRef.current?.(newState); + enqueueSessionUpdate({ + running: true, + time_left: newState.timeLeft, + total_time: newState.totalTime, + mode: newState.mode, + pomos_done: focusCountRef.current, + }); } - } else if ((currentMode === 'short' || currentMode === 'long') && settings.autoStartPomodoros) { - const newState = skipAndStartRef.current?.('focus', durations) - if (!newState) return + } else if ( + (currentMode === "short" || currentMode === "long") && + settings.autoStartPomodoros + ) { + const newState = skipAndStartRef.current?.("focus", durations); + if (!newState) return; if (canControlRef.current) { - broadcastTimerStateRef.current?.(newState) - enqueueSessionUpdate({ running: true, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode }) + broadcastTimerStateRef.current?.(newState); + enqueueSessionUpdate({ + running: true, + time_left: newState.timeLeft, + total_time: newState.totalTime, + mode: newState.mode, + }); } } else { - setShowBreakOverlay(true) + setShowBreakOverlay(true); } - }, [userId, supabase, session.id, enqueueSessionUpdate]) + }, [userId, supabase, session.id, enqueueSessionUpdate]); const { - timeLeft, status, mode, timerState, - start, pause, reset, setMode, applyState, skipAndStart, + timeLeft, + status, + mode, + timerState, + start, + pause, + reset, + setMode, + applyState, + skipAndStart, } = useTimer({ initialState: sessionToTimerState(session), onExpire: handleExpire, - }) + }); - const { participants, isConnected, broadcastTimerState, onTimerUpdate, broadcastShareLock, onShareLock, broadcastSessionMode, onSessionMode, onParticipantJoin, onParticipantLeave, broadcastActivity, onActivity, updatePresence, broadcastSettingsRequest, onSettingsRequest, broadcastSettingsResponse, onSettingsResponse } = useSession({ + const { + participants, + isConnected, + broadcastTimerState, + onTimerUpdate, + broadcastShareLock, + onShareLock, + broadcastSessionMode, + onSessionMode, + onParticipantJoin, + onParticipantLeave, + broadcastActivity, + onActivity, + updatePresence, + broadcastSettingsRequest, + onSettingsRequest, + broadcastSettingsResponse, + onSettingsResponse, + } = useSession({ sessionId: session.id, userId, isHost, username: localUsername, avatarUrl, - }) + }); + + // Bonfire state — derived from timer + presence, drives BonfireScene intensity + const bonfireState = useBonfireState({ + status, + mode, + focusCount, + participantCount: participants.length, + }); // Enrich every timer broadcast with the current focusCount so watchers stay in sync - const broadcastWithCount = useCallback((state: TimerState) => { - broadcastTimerState({ ...state, focusCount: focusCountRef.current }) - }, [broadcastTimerState]) + const broadcastWithCount = useCallback( + (state: TimerState) => { + broadcastTimerState({ ...state, focusCount: focusCountRef.current }); + }, + [broadcastTimerState], + ); // Keep late-bound refs up to date - useEffect(() => { skipAndStartRef.current = skipAndStart }, [skipAndStart]) - useEffect(() => { broadcastTimerStateRef.current = broadcastWithCount }, [broadcastWithCount]) + useEffect(() => { + skipAndStartRef.current = skipAndStart; + }, [skipAndStart]); + useEffect(() => { + broadcastTimerStateRef.current = broadcastWithCount; + }, [broadcastWithCount]); // Clear pending settings request if channel disconnects — avoids infinite spinner useEffect(() => { - if (!isConnected) setPendingSettingsRequest(false) - }, [isConnected]) + if (!isConnected) setPendingSettingsRequest(false); + }, [isConnected]); // Auto-cancel pending settings request after 30s if host never responds useEffect(() => { - if (!pendingSettingsRequest) return - const t = setTimeout(() => setPendingSettingsRequest(false), 30_000) - return () => clearTimeout(t) - }, [pendingSettingsRequest]) + if (!pendingSettingsRequest) return; + const t = setTimeout(() => setPendingSettingsRequest(false), 30_000); + return () => clearTimeout(t); + }, [pendingSettingsRequest]); // Keep refs up to date - useEffect(() => { modeRef.current = mode }, [mode]) + useEffect(() => { + modeRef.current = mode; + }, [mode]); // Short display name for activity messages — prefers live localUsername (guest nick) - const actorName = localUsername ?? 'Guest' + const actorName = localUsername ?? "Guest"; // Push an ephemeral activity item — auto-removes after animation completes const pushActivity = useCallback((text: string) => { - totalLogCountRef.current++ - sessionLogRef.current = [...sessionLogRef.current, text].slice(-10) - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` - setActivities(prev => [...prev.slice(-2), { id, text }]) - setTimeout(() => setActivities(prev => prev.filter(a => a.id !== id)), 5050) - }, []) + totalLogCountRef.current++; + sessionLogRef.current = [...sessionLogRef.current, text].slice(-10); + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + setActivities((prev) => [...prev.slice(-2), { id, text }]); + setTimeout( + () => setActivities((prev) => prev.filter((a) => a.id !== id)), + 5050, + ); + }, []); useEffect(() => { function handleVisibilityChange() { - if (document.visibilityState === 'hidden') { - tabHiddenAtRef.current = Date.now() - logCountAtHideRef.current = totalLogCountRef.current + if (document.visibilityState === "hidden") { + tabHiddenAtRef.current = Date.now(); + logCountAtHideRef.current = totalLogCountRef.current; } else { - if (tabHiddenAtRef.current === null) return - tabHiddenAtRef.current = null - const newCount = totalLogCountRef.current - logCountAtHideRef.current - const missed = newCount > 0 ? sessionLogRef.current.slice(-newCount) : [] - if (missed.length === 0) return - setActivities([]) - setMissedEvents(missed) - if (missedEventsTimerRef.current) clearTimeout(missedEventsTimerRef.current) - missedEventsTimerRef.current = setTimeout(() => setMissedEvents([]), 4000) + if (tabHiddenAtRef.current === null) return; + tabHiddenAtRef.current = null; + const newCount = totalLogCountRef.current - logCountAtHideRef.current; + const missed = + newCount > 0 ? sessionLogRef.current.slice(-newCount) : []; + if (missed.length === 0) return; + setActivities([]); + setMissedEvents(missed); + if (missedEventsTimerRef.current) + clearTimeout(missedEventsTimerRef.current); + missedEventsTimerRef.current = setTimeout( + () => setMissedEvents([]), + 4000, + ); } } - document.addEventListener('visibilitychange', handleVisibilityChange) + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - if (missedEventsTimerRef.current) clearTimeout(missedEventsTimerRef.current) - } - }, []) + document.removeEventListener("visibilitychange", handleVisibilityChange); + if (missedEventsTimerRef.current) + clearTimeout(missedEventsTimerRef.current); + }; + }, []); // Receive activity broadcasts from other participants useEffect(() => { - return onActivity(pushActivity) - }, [onActivity, pushActivity]) + return onActivity(pushActivity); + }, [onActivity, pushActivity]); // Join / leave presence events → local activity messages useEffect(() => { return onParticipantJoin((joinedUsername, isReconnect) => { // Always resync timer state to the (re)joining participant - if (isHost) broadcastWithCount(timerStateRef.current) + if (isHost) broadcastWithCount(timerStateRef.current); // Suppress activity message on quick reconnects (tab switch / brief disconnect) if (!isReconnect) { - const name = joinedUsername ?? 'Someone' - pushActivity(`${name} joined 👋`) + const name = joinedUsername ?? "Someone"; + pushActivity(`${name} joined 👋`); } - }) - }, [onParticipantJoin, pushActivity, isHost, broadcastWithCount]) + }); + }, [onParticipantJoin, pushActivity, isHost, broadcastWithCount]); useEffect(() => { return onParticipantLeave((leftUsername) => { - const name = leftUsername ?? 'Someone' - pushActivity(`${name} left the room`) - }) - }, [onParticipantLeave, pushActivity]) + const name = leftUsername ?? "Someone"; + pushActivity(`${name} left the room`); + }); + }, [onParticipantLeave, pushActivity]); // Keep a ref to timerState so join handler always has the latest without re-subscribing - const timerStateRef = useRef(timerState) - useEffect(() => { timerStateRef.current = timerState }, [timerState]) + const timerStateRef = useRef(timerState); + useEffect(() => { + timerStateRef.current = timerState; + }, [timerState]); // Always receive timer updates from other controllers. // broadcast: { self: false } ensures you never apply your own broadcasts. // In Jam mode this is essential — every participant must sync with whoever just acted. useEffect(() => { const unsubscribe = onTimerUpdate((state: TimerState) => { - applyState(state) + applyState(state); // Sync focusCount from host broadcasts so round label stays accurate for watchers if (state.focusCount !== undefined) { - focusCountRef.current = state.focusCount - setFocusCount(state.focusCount) + focusCountRef.current = state.focusCount; + setFocusCount(state.focusCount); } - }) - return unsubscribe - }, [onTimerUpdate, applyState]) + }); + return unsubscribe; + }, [onTimerUpdate, applyState]); // (join re-broadcast + activity handled in the pushActivity/onParticipantJoin block above) // Receive share lock updates (non-hosts) useEffect(() => { - if (isHost) return + if (isHost) return; const unsubscribe = onShareLock((locked) => { - setSessionSettings(prev => ({ ...prev, allowGuestShare: !locked })) - }) - return unsubscribe - }, [isHost, onShareLock]) + setSessionSettings((prev) => ({ ...prev, allowGuestShare: !locked })); + }); + return unsubscribe; + }, [isHost, onShareLock]); // Close share panel on outside click useEffect(() => { function handleClick(e: MouseEvent) { - if (sharePanelRef.current && !sharePanelRef.current.contains(e.target as Node)) { - setShowSharePanel(false) + if ( + sharePanelRef.current && + !sharePanelRef.current.contains(e.target as Node) + ) { + setShowSharePanel(false); } } if (showSharePanel) { - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); } - }, [showSharePanel]) + }, [showSharePanel]); // Close settings panel on outside click useEffect(() => { function handleClick(e: MouseEvent) { - const inGear = settingsPanelRef.current?.contains(e.target as Node) - const inDesktop = settingsPanelDesktopRef.current?.contains(e.target as Node) + const inGear = settingsPanelRef.current?.contains(e.target as Node); + const inDesktop = settingsPanelDesktopRef.current?.contains( + e.target as Node, + ); if (!inGear && !inDesktop) { - setShowSettings(false) + setShowSettings(false); } } if (showSettings) { - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); } - }, [showSettings]) + }, [showSettings]); // Close watcher settings panel on outside click useEffect(() => { function handleClick(e: MouseEvent) { - const inGear = watcherSettingsPanelRef.current?.contains(e.target as Node) - const inDesktop = watcherSettingsPanelDesktopRef.current?.contains(e.target as Node) + const inGear = watcherSettingsPanelRef.current?.contains( + e.target as Node, + ); + const inDesktop = watcherSettingsPanelDesktopRef.current?.contains( + e.target as Node, + ); if (!inGear && !inDesktop) { - setShowWatcherSettings(false) + setShowWatcherSettings(false); } } if (showWatcherSettings) { - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); } - }, [showWatcherSettings]) + }, [showWatcherSettings]); + // Request notification permission useEffect(() => { if (!hasRequestedNotifRef.current) { - hasRequestedNotifRef.current = true - requestNotificationPermission() + hasRequestedNotifRef.current = true; + requestNotificationPermission(); } - }, []) + }, []); // Track latest participants in a ref so the heartbeat can read it without restarting the interval - const participantsRef = useRef(participants) - useEffect(() => { participantsRef.current = participants }, [participants]) + const participantsRef = useRef(participants); + useEffect(() => { + participantsRef.current = participants; + }, [participants]); // Write participant count + preview immediately whenever the list changes (so explore reflects joins fast) useEffect(() => { - if (!isHost) return - const preview = participants.slice(0, 3).map(p => ({ username: p.username ?? null, avatar_url: p.avatar_url ?? null })) - supabase.from('sessions').update({ participant_count: participants.length, participant_preview: preview }).eq('id', session.id) - .then(({ error }) => { if (error) console.error('[participant-sync] Failed:', error) }) - }, [isHost, participants, supabase, session.id]) + if (!isHost) return; + const preview = participants + .slice(0, 3) + .map((p) => ({ + username: p.username ?? null, + avatar_url: p.avatar_url ?? null, + })); + supabase + .from("sessions") + .update({ + participant_count: participants.length, + participant_preview: preview, + }) + .eq("id", session.id) + .then(({ error }) => { + if (error) console.error("[participant-sync] Failed:", error); + }); + }, [isHost, participants, supabase, session.id]); // Host heartbeat — keeps last_active_at + participant data fresh for explore page useEffect(() => { - if (!isHost) return + if (!isHost) return; const id = setInterval(() => { - const preview = participantsRef.current.slice(0, 3).map(p => ({ username: p.username ?? null, avatar_url: p.avatar_url ?? null })) - supabase.from('sessions').update({ - last_active_at: new Date().toISOString(), - participant_count: participantsRef.current.length, - participant_preview: preview, - }).eq('id', session.id) - .then(({ error }) => { if (error) console.error('[heartbeat] Failed:', error) }) - }, 30_000) - return () => clearInterval(id) - }, [isHost, supabase, session.id]) + const preview = participantsRef.current + .slice(0, 3) + .map((p) => ({ + username: p.username ?? null, + avatar_url: p.avatar_url ?? null, + })); + supabase + .from("sessions") + .update({ + last_active_at: new Date().toISOString(), + participant_count: participantsRef.current.length, + participant_preview: preview, + }) + .eq("id", session.id) + .then(({ error }) => { + if (error) console.error("[heartbeat] Failed:", error); + }); + }, 30_000); + return () => clearInterval(id); + }, [isHost, supabase, session.id]); // Warn host before closing tab while timer is running useEffect(() => { - if (!isHost) return + if (!isHost) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (status === 'running') { - e.preventDefault() + if (status === "running") { + e.preventDefault(); } - } - window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) - }, [isHost, status]) + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isHost, status]); // Set session mode (host only) — await DB first, then broadcast to avoid state divergence - const handleSetMode = useCallback(async (mode: 'host' | 'jam' | 'solo') => { - if (mode === sessionMode) return - const { error } = await supabase - .from('sessions') - .update({ session_mode: mode, jam_mode: mode === 'jam' }) - .eq('id', session.id) - if (error) { - console.error('[handleSetMode] DB update failed:', error) - return - } - setSessionMode(mode) - broadcastSessionMode(mode) - const messages: Record<'host' | 'jam' | 'solo', string> = { - host: `${actorName} switched to Host mode 👑`, - jam: `${actorName} switched to Jam mode ⚡`, - solo: `${actorName} switched to Solo mode 🎯`, - } - broadcastActivity(messages[mode]) - }, [sessionMode, session.id, supabase, broadcastSessionMode, broadcastActivity, actorName]) + const handleSetMode = useCallback( + async (mode: "host" | "jam" | "solo") => { + if (mode === sessionMode) return; + const { error } = await supabase + .from("sessions") + .update({ session_mode: mode, jam_mode: mode === "jam" }) + .eq("id", session.id); + if (error) { + console.error("[handleSetMode] DB update failed:", error); + return; + } + setSessionMode(mode); + broadcastSessionMode(mode); + const messages: Record<"host" | "jam" | "solo", string> = { + host: `${actorName} switched to Host mode`, + jam: `${actorName} switched to Jam mode`, + solo: `${actorName} switched to Solo mode`, + }; + broadcastActivity(messages[mode]); + }, + [ + sessionMode, + session.id, + supabase, + broadcastSessionMode, + broadcastActivity, + actorName, + ], + ); // Non-hosts receive session mode changes from the host useEffect(() => { - if (isHost) return + if (isHost) return; const unsubscribe = onSessionMode((next) => { - setSessionMode(next) - }) - return unsubscribe - }, [isHost, onSessionMode]) + setSessionMode(next); + }); + return unsubscribe; + }, [isHost, onSessionMode]); // Host receives settings change requests from watchers useEffect(() => { - if (!isHost) return + if (!isHost) return; const unsubscribe = onSettingsRequest((request) => { // Auto-reject any existing pending request before accepting the new one, // so the first requester isn't left waiting indefinitely if (pendingRequestRef.current) { - broadcastSettingsResponse({ requester_id: pendingRequestRef.current.requester_id, accepted: false }) + broadcastSettingsResponse({ + requester_id: pendingRequestRef.current.requester_id, + accepted: false, + }); } - setPendingRequest(request) - }) - return unsubscribe - }, [isHost, onSettingsRequest, broadcastSettingsResponse]) + setPendingRequest(request); + }); + return unsubscribe; + }, [isHost, onSettingsRequest, broadcastSettingsResponse]); // Watchers receive response from host (accepted/rejected) useEffect(() => { - if (isHost) return + if (isHost) return; const unsubscribe = onSettingsResponse((response) => { - const guestId = typeof window !== 'undefined' ? localStorage.getItem('pomodoro_guest_id') : null - const myId = userId ?? guestId - if (response.requester_id !== myId) return - setPendingSettingsRequest(false) + const guestId = + typeof window !== "undefined" + ? localStorage.getItem("pomodoro_guest_id") + : null; + const myId = userId ?? guestId; + if (response.requester_id !== myId) return; + setPendingSettingsRequest(false); if (response.accepted && response.settings) { - const settings = response.settings + const settings = response.settings; // Apply the accepted settings locally so round labels and UI stay in sync - setSessionSettings(prev => ({ - durations: { focus: settings.focus, short: settings.short, long: settings.long }, + setSessionSettings((prev) => ({ + durations: { + focus: settings.focus, + short: settings.short, + long: settings.long, + }, rounds: settings.rounds, allowGuestShare: prev.allowGuestShare, // host-only setting, preserve watcher's current value autoStartBreaks: settings.autoStartBreaks, autoStartPomodoros: settings.autoStartPomodoros, - })) - toast('Host accepted your settings request ✓', 'success') - setShowWatcherSettings(false) + })); + toast("Host accepted your settings request ✓", "success"); + setShowWatcherSettings(false); } else if (!response.accepted) { - toast('Host declined your settings request', 'error') + toast("Host declined your settings request", "error"); } - }) - return unsubscribe - }, [isHost, onSettingsResponse, userId, toast]) - - const handleSendSettingsRequest = useCallback((newSettings: SessionSettings) => { - const guestId = typeof window !== 'undefined' ? localStorage.getItem('pomodoro_guest_id') : null - const myId = userId ?? guestId ?? 'unknown' - broadcastSettingsRequest({ - requester_id: myId, - requester_name: localUsername, - focus: newSettings.durations.focus, - short: newSettings.durations.short, - long: newSettings.durations.long, - rounds: newSettings.rounds, - autoStartBreaks: newSettings.autoStartBreaks, - autoStartPomodoros: newSettings.autoStartPomodoros, - }) - setPendingSettingsRequest(true) - toast('Settings request sent to host', 'info') - }, [userId, localUsername, broadcastSettingsRequest, toast]) + }); + return unsubscribe; + }, [isHost, onSettingsResponse, userId, toast]); + + const handleSendSettingsRequest = useCallback( + (newSettings: SessionSettings) => { + const guestId = + typeof window !== "undefined" + ? localStorage.getItem("pomodoro_guest_id") + : null; + const myId = userId ?? guestId ?? "unknown"; + broadcastSettingsRequest({ + requester_id: myId, + requester_name: localUsername, + focus: newSettings.durations.focus, + short: newSettings.durations.short, + long: newSettings.durations.long, + rounds: newSettings.rounds, + autoStartBreaks: newSettings.autoStartBreaks, + autoStartPomodoros: newSettings.autoStartPomodoros, + }); + setPendingSettingsRequest(true); + toast("Settings request sent to host", "info"); + }, + [userId, localUsername, broadcastSettingsRequest, toast], + ); const handleStart = useCallback(() => { - const newState = start() - const msg = `${actorName} started the timer 🍅` - broadcastActivity(msg) + const newState = start(); + const msg = `${actorName} started the timer 🍅`; + broadcastActivity(msg); if (canControl) { - broadcastWithCount(newState) - enqueueSessionUpdate({ running: true, time_left: newState.timeLeft, mode: newState.mode }) + broadcastWithCount(newState); + enqueueSessionUpdate({ + running: true, + time_left: newState.timeLeft, + mode: newState.mode, + }); } - }, [start, actorName, canControl, broadcastWithCount, broadcastActivity, enqueueSessionUpdate]) + }, [ + start, + actorName, + canControl, + broadcastWithCount, + broadcastActivity, + enqueueSessionUpdate, + ]); const handlePause = useCallback(() => { - const newState = pause() - const mins = String(Math.floor(newState.timeLeft / 60)).padStart(2, '0') - const secs = String(newState.timeLeft % 60).padStart(2, '0') - const msg = `${actorName} paused — ${mins}:${secs} remaining ⏸` - broadcastActivity(msg) + const newState = pause(); + const mins = String(Math.floor(newState.timeLeft / 60)).padStart(2, "0"); + const secs = String(newState.timeLeft % 60).padStart(2, "0"); + const msg = `${actorName} paused, ${mins}:${secs} remaining ⏸`; + broadcastActivity(msg); if (canControl) { - broadcastWithCount(newState) - enqueueSessionUpdate({ running: false, time_left: newState.timeLeft }) + broadcastWithCount(newState); + enqueueSessionUpdate({ running: false, time_left: newState.timeLeft }); } - }, [pause, actorName, canControl, broadcastWithCount, broadcastActivity, enqueueSessionUpdate]) + }, [ + pause, + actorName, + canControl, + broadcastWithCount, + broadcastActivity, + enqueueSessionUpdate, + ]); // Global keyboard shortcuts (placed after handleStart/handlePause declarations) useEffect(() => { function handleKey(e: KeyboardEvent) { - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return - if (e.key === '?') { - e.preventDefault() - setShowShortcutsModal(v => !v) - return + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + if (e.key === "?") { + e.preventDefault(); + setShowGuideModal((v) => !v); + return; } - if (showShortcutsModal) return - if (e.code === 'Space' && canControl) { - e.preventDefault() - if (status === 'running') handlePause() - else handleStart() + if (showGuideModal) return; + if (e.code === "Space" && canControl) { + e.preventDefault(); + if (status === "running") handlePause(); + else handleStart(); } } - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [showShortcutsModal, canControl, status, handleStart, handlePause]) + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [showGuideModal, canControl, status, handleStart, handlePause]); const handleReset = useCallback(() => { - const newState = reset(toSecs(sessionSettings.durations)) - setShowBreakOverlay(false) - const msg = `${actorName} reset the timer ↺` - broadcastActivity(msg) + const newState = reset(toSecs(sessionSettings.durations)); + setShowBreakOverlay(false); + const msg = `${actorName} reset the timer ↺`; + broadcastActivity(msg); if (canControl) { - broadcastWithCount(newState) - enqueueSessionUpdate({ running: false, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode }) + broadcastWithCount(newState); + enqueueSessionUpdate({ + running: false, + time_left: newState.timeLeft, + total_time: newState.totalTime, + mode: newState.mode, + }); } - }, [reset, actorName, canControl, broadcastWithCount, broadcastActivity, sessionSettings.durations, enqueueSessionUpdate]) + }, [ + reset, + actorName, + canControl, + broadcastWithCount, + broadcastActivity, + sessionSettings.durations, + enqueueSessionUpdate, + ]); const handleSkip = useCallback(() => { - let nextMode: TimerMode - const skippingFocus = mode === 'focus' + let nextMode: TimerMode; + const skippingFocus = mode === "focus"; + // Only count a focus completion when the timer has naturally finished — + // skipping mid-session should not inflate stats. + const countCompletion = skippingFocus && status === "finished"; if (skippingFocus) { - focusCountRef.current += 1 - setFocusCount(focusCountRef.current) - setTodayCount(prev => (prev ?? 0) + 1) - nextMode = focusCountRef.current % sessionSettings.rounds === 0 ? 'long' : 'short' + if (countCompletion) { + focusCountRef.current += 1; + setFocusCount(focusCountRef.current); + setTodayCount((prev) => (prev ?? 0) + 1); + } + nextMode = + focusCountRef.current % sessionSettings.rounds === 0 ? "long" : "short"; } else { - nextMode = 'focus' + nextMode = "focus"; } const modeMessages: Record = { short: `Short break started ☕`, - long: `Long break — take a proper rest 🎉`, + long: `Long break. Take a proper rest 🎉`, focus: `${actorName} started a new focus round 🍅`, - } - const msg = modeMessages[nextMode] - broadcastActivity(msg) - const newState = setMode(nextMode, toSecs(sessionSettings.durations)) - setShowBreakOverlay(false) + }; + const msg = modeMessages[nextMode]; + broadcastActivity(msg); + const newState = setMode(nextMode, toSecs(sessionSettings.durations)); + setShowBreakOverlay(false); if (canControl) { - broadcastWithCount(newState) + broadcastWithCount(newState); enqueueSessionUpdate({ running: false, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode, - ...(skippingFocus ? { pomos_done: focusCountRef.current } : {}), - }) + ...(countCompletion ? { pomos_done: focusCountRef.current } : {}), + }); } - }, [mode, actorName, setMode, canControl, broadcastWithCount, broadcastActivity, sessionSettings.durations, sessionSettings.rounds, enqueueSessionUpdate]) - - const handleModeChange = useCallback((newMode: TimerMode) => { - const modeMessages: Record = { - short: `Short break started ☕`, - long: `Long break — take a proper rest 🎉`, - focus: `${actorName} started a new focus round 🍅`, - } - const msg = modeMessages[newMode] - broadcastActivity(msg) - const newState = setMode(newMode, toSecs(sessionSettings.durations)) - setShowBreakOverlay(false) - if (canControl) { - broadcastWithCount(newState) - enqueueSessionUpdate({ running: false, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode }) - } - }, [setMode, actorName, canControl, broadcastWithCount, broadcastActivity, sessionSettings.durations, enqueueSessionUpdate]) - - const handleApplySettings = useCallback(async (newSettings: SessionSettings) => { - setSessionSettings(newSettings) - setShowSettings(false) - const newState = reset(toSecs(newSettings.durations)) - if (canControl) { - const { error } = await supabase.from('sessions').update({ - running: false, - time_left: newState.timeLeft, - mode: newState.mode, - settings: { - focus: newSettings.durations.focus, - short: newSettings.durations.short, - long: newSettings.durations.long, - rounds: newSettings.rounds, - allowGuestShare: newSettings.allowGuestShare, - autoStartBreaks: newSettings.autoStartBreaks, - autoStartPomodoros: newSettings.autoStartPomodoros, - }, - }).eq('id', session.id) - if (error) { - console.error('[handleApplySettings] DB update failed:', error) - return false - } else { - broadcastWithCount(newState) - broadcastShareLock(!newSettings.allowGuestShare) + }, [ + mode, + actorName, + setMode, + canControl, + broadcastWithCount, + broadcastActivity, + sessionSettings.durations, + sessionSettings.rounds, + enqueueSessionUpdate, + ]); + + const handleModeChange = useCallback( + (newMode: TimerMode) => { + const modeMessages: Record = { + short: `Short break started ☕`, + long: `Long break. Take a proper rest 🎉`, + focus: `${actorName} started a new focus round 🍅`, + }; + const msg = modeMessages[newMode]; + broadcastActivity(msg); + const newState = setMode(newMode, toSecs(sessionSettings.durations)); + setShowBreakOverlay(false); + if (canControl) { + broadcastWithCount(newState); + enqueueSessionUpdate({ + running: false, + time_left: newState.timeLeft, + total_time: newState.totalTime, + mode: newState.mode, + }); } - } - return true - }, [reset, canControl, broadcastWithCount, broadcastShareLock, supabase, session.id]) - - const isTogglingPublic = useRef(false) - - const handleTogglePublic = useCallback(async (newValue: boolean) => { - if (isTogglingPublic.current) return - isTogglingPublic.current = true - const oldValue = isPublic - setIsPublic(newValue) - const { error } = await supabase - .from('sessions') - .update({ is_public: newValue }) - .eq('id', session.id) - if (error) { - console.error('[handleTogglePublic] DB update failed:', error) - setIsPublic(oldValue) - } - isTogglingPublic.current = false - }, [isPublic, supabase, session.id]) + }, + [ + setMode, + actorName, + canControl, + broadcastWithCount, + broadcastActivity, + sessionSettings.durations, + enqueueSessionUpdate, + ], + ); + + const handleApplySettings = useCallback( + async (newSettings: SessionSettings) => { + const newState = reset(toSecs(newSettings.durations)); + if (canControl) { + const { error } = await supabase + .from("sessions") + .update({ + running: false, + time_left: newState.timeLeft, + mode: newState.mode, + settings: { + focus: newSettings.durations.focus, + short: newSettings.durations.short, + long: newSettings.durations.long, + rounds: newSettings.rounds, + allowGuestShare: newSettings.allowGuestShare, + autoStartBreaks: newSettings.autoStartBreaks, + autoStartPomodoros: newSettings.autoStartPomodoros, + }, + }) + .eq("id", session.id); + if (error) { + console.error("[handleApplySettings] DB update failed:", error); + return false; + } + broadcastWithCount(newState); + broadcastShareLock(!newSettings.allowGuestShare); + } + // Apply local state only after DB confirms (or in solo/watcher mode) + setSessionSettings(newSettings); + setShowSettings(false); + return true; + }, + [ + reset, + canControl, + broadcastWithCount, + broadcastShareLock, + supabase, + session.id, + ], + ); + + const isTogglingPublic = useRef(false); + + const handleTogglePublic = useCallback( + async (newValue: boolean) => { + if (isTogglingPublic.current) return; + isTogglingPublic.current = true; + const oldValue = isPublic; + setIsPublic(newValue); + try { + const { error } = await supabase + .from("sessions") + .update({ is_public: newValue }) + .eq("id", session.id); + if (error) { + console.error("[handleTogglePublic] DB update failed:", error); + setIsPublic(oldValue); + } + } finally { + isTogglingPublic.current = false; + } + }, + [isPublic, supabase, session.id], + ); const handleAcceptRequest = useCallback(async () => { - if (!pendingRequest) return + if (!pendingRequest) return; const newSettings: SessionSettings = { - durations: { focus: pendingRequest.focus, short: pendingRequest.short, long: pendingRequest.long }, + durations: { + focus: pendingRequest.focus, + short: pendingRequest.short, + long: pendingRequest.long, + }, rounds: pendingRequest.rounds, allowGuestShare: sessionSettings.allowGuestShare, autoStartBreaks: pendingRequest.autoStartBreaks, autoStartPomodoros: pendingRequest.autoStartPomodoros, - } - const ok = await handleApplySettings(newSettings) + }; + const ok = await handleApplySettings(newSettings); broadcastSettingsResponse({ requester_id: pendingRequest.requester_id, accepted: ok, // Include settings so the watcher can apply them locally without a separate broadcast - settings: ok ? { - focus: newSettings.durations.focus, - short: newSettings.durations.short, - long: newSettings.durations.long, - rounds: newSettings.rounds, - autoStartBreaks: newSettings.autoStartBreaks, - autoStartPomodoros: newSettings.autoStartPomodoros, - } : undefined, - }) - setPendingRequest(null) - }, [pendingRequest, sessionSettings.allowGuestShare, handleApplySettings, broadcastSettingsResponse]) + settings: ok + ? { + focus: newSettings.durations.focus, + short: newSettings.durations.short, + long: newSettings.durations.long, + rounds: newSettings.rounds, + autoStartBreaks: newSettings.autoStartBreaks, + autoStartPomodoros: newSettings.autoStartPomodoros, + } + : undefined, + }); + setPendingRequest(null); + }, [ + pendingRequest, + sessionSettings.allowGuestShare, + handleApplySettings, + broadcastSettingsResponse, + ]); const handleRejectRequest = useCallback(() => { - if (!pendingRequest) return - broadcastSettingsResponse({ requester_id: pendingRequest.requester_id, accepted: false }) - setPendingRequest(null) - }, [pendingRequest, broadcastSettingsResponse]) + if (!pendingRequest) return; + broadcastSettingsResponse({ + requester_id: pendingRequest.requester_id, + accepted: false, + }); + setPendingRequest(null); + }, [pendingRequest, broadcastSettingsResponse]); - const progress = computeProgress(timerState) - const roundLabel = `${focusCount} pomodoro${focusCount !== 1 ? 's' : ''} completed` + const labelCount = userId ? (todayCount ?? 0) : focusCount; + const labelScope = userId ? "today" : "in this room"; + const roundLabel = `${labelCount} pomodoro${labelCount !== 1 ? "s" : ""} completed ${labelScope}`; return (
{/* Header */}
- + {/* Tab nav — centered absolutely */} -
- {(['timer', 'tasks', 'stats'] as const).map((tab) => { - const label = tab.charAt(0).toUpperCase() + tab.slice(1) - const active = activeTab === tab +
+ {(["timer", "tasks", "stats"] as const).map((tab) => { + const label = tab.charAt(0).toUpperCase() + tab.slice(1); + const active = activeTab === tab; return ( - ) + ); })}
-
+
+ {/* Connection status — only shown when disconnected */} + {!isConnected && ( +
+ + Connecting +
+ )} - {/* Connection status */} -
- {isConnected ? ( - - ) : ( - - )} - {isConnected ? 'Live' : 'Connecting'} -
+ Explore + + + {/* Avatar / sign-in */} + {profileUsername ? ( + + + + ) : !userId && ( + + + + )} + {/* Guide */} -
{/* Jam mode banner for watchers */} - {sessionMode === 'jam' && !isHost && ( + {sessionMode === "jam" && !isHost && (
- Jam Mode: everyone controls the timer
)} {/* Stats tab */} - {activeTab === 'stats' && ( + {activeTab === "stats" && (
- +
)} {/* Tasks tab */} - {activeTab === 'tasks' && ( + {activeTab === "tasks" && (
-

Tasks

-

Coming in an upcoming update.

-
- )} - - {/* Main */} -
- {/* Room name */} - {session.title && ( -
-

Room

-

- {session.title} -

-
- )} - - {/* Timer card */} -
- - - {/* Round indicator */}

- {roundLabel} + Tasks +

+

+ Coming in an upcoming update.

+
+ )} - {/* Today's pomodoro count — auth users only */} - {userId && todayCount !== null && ( -

- #{todayCount} today + {/* Main */} +

+ {/* Timer area — no card, fire floats on page background */} +
+ {/* Mode selector + round info */} +
+ +

+ {roundLabel}

- )} - - {/* Timer ring */} -
- - - -
-
- - -
- - {/* Share + Jam row */} -
- {/* Invite button — hidden for viewers when host has locked sharing, and hidden in solo mode */} - {(isHost || sessionSettings.allowGuestShare) && sessionMode !== 'solo' && ( -
- + /> + {session.title.toUpperCase()} +

+ )} - {showSharePanel && ( -
- + {/* ── Timer display ─────────────────────────────────────────────── */} + + + {/* ── Controls + rest of card — bottom padding section ─────────── */} +
+ + + {/* Session mode + Focus music — side-by-side compact dropdowns */} + {isHost && ( +
+
+ +
+ {(["host", "jam", "solo"] as const).map((m, i) => { + const isActive = sessionMode === m; + const isDisabled = m === "solo" && participants.length > 1; + const labels = { host: "Host", jam: "Jam", solo: "Solo" }; + return ( + + ); + })} +
+

+ {sessionMode === "host" && + "You start, pause, and skip. Participants just watch."} + {sessionMode === "jam" && + "Anyone in the room can control the timer."} + {sessionMode === "solo" && + "Private session. New participants cannot join."} +

- )} -
+
+ + +
+
)} + {/* Focus music for watchers */} {!isHost && ( - <> - {/* Watcher settings request button — centred in remaining space */} -
-
+
+ + +
+ )} + + + + {/* Separator */} +
+ + {/* Actions row: Invite (prominent) + Settings gear */} +
+ {/* Invite button — hidden when host locked sharing, or in solo mode */} + {(isHost || sessionSettings.allowGuestShare) && + sessionMode !== "solo" && ( +
- {showWatcherSettings && ( - <> - {/* Backdrop + bottom sheet — mobile only */} -
setShowWatcherSettings(false)} + {showSharePanel && ( +
+ -
-
- {pendingSettingsRequest ? ( -
- -

- Waiting for host approval... -

-

- The host will accept or decline your request. -

- -
- ) : ( - - )} -
-
- +
)}
-
- - )} + )} - {isHost && ( - <> - {/* Mode selector: Host | Jam | Solo */} -
-
- {(['host', 'jam', 'solo'] as const).map((m) => { - const active = sessionMode === m - const label = m === 'host' ? 'Host' : m === 'jam' ? 'Jam' : 'Solo' - const activeColor = m === 'host' ? 'var(--accent)' : m === 'jam' ? 'var(--green)' : '#8B5CF6' - const title = m === 'host' - ? 'You control the timer. Everyone else follows along in sync.' - : m === 'jam' - ? 'Everyone in the room can control the timer.' - : 'Private room. No sharing, no watchers.' - const soloBlocked = m === 'solo' && participants.length > 1 - return ( - - ) - })} -
-
+ {/* Spacer */} +
- {/* Settings gear */} + {/* Settings gear — host */} + {isHost && (
- {/* Ambient sound — collapsible */} -
- - {showAmbient && ( -
- -
- )} + {showWatcherSettings && ( + <> +
setShowWatcherSettings(false)} + /> +
+
+ {pendingSettingsRequest ? ( +
+ +

+ Waiting for host approval... +

+

+ The host will accept or decline your request. +

+ +
+ ) : ( + + )} +
+
+ + )} +
+ )} +
+ {/* end bottom section */}
-
{/* Desktop settings panel — host. Fixed in right free space, vertically centred. @@ -1086,20 +1495,24 @@ function SessionContent({
@@ -1112,32 +1525,42 @@ function SessionContent({
{pendingSettingsRequest ? (
-

+

Waiting for host approval...

-

+

The host will accept or decline your request.

@@ -1153,20 +1576,28 @@ function SessionContent({
)} - {isHost && } + {isHost && ( + + )} { - setShowBreakOverlay(false) - if (canControl) handleSkip() + setShowBreakOverlay(false); + if (canControl) handleSkip(); }} mode={mode} canControl={canControl} /> {missedEvents.length > 0 && ( - setMissedEvents([])} /> + setMissedEvents([])} + /> )} @@ -1174,7 +1605,7 @@ function SessionContent({ @@ -1183,29 +1614,29 @@ function SessionContent({ {showNicknamePrompt && ( { - setLocalUsername(name) - localStorage.setItem(`pomodoro_nick_${session.id}`, name) - updatePresence(name) - setShowNicknamePrompt(false) - setNicknameReady(true) + setLocalUsername(name); + localStorage.setItem(`pomodoro_nick_${session.id}`, name); + updatePresence(name); + setShowNicknamePrompt(false); + setNicknameReady(true); }} onSkip={() => { // Safety net: assign a random name instead of leaving username null - const fallback = generateUsername() - setLocalUsername(fallback) - localStorage.setItem(`pomodoro_nick_${session.id}`, fallback) - updatePresence(fallback) - setShowNicknamePrompt(false) - setNicknameReady(true) + const fallback = generateUsername(); + setLocalUsername(fallback); + localStorage.setItem(`pomodoro_nick_${session.id}`, fallback); + updatePresence(fallback); + setShowNicknamePrompt(false); + setNicknameReady(true); }} /> )} - {showShortcutsModal && ( - setShowShortcutsModal(false)} /> + {showGuideModal && ( + setShowGuideModal(false)} /> )}
- ) + ); } export function SessionProvider(props: SessionProviderProps) { @@ -1213,5 +1644,5 @@ export function SessionProvider(props: SessionProviderProps) { - ) + ); } diff --git a/components/session/SettingsPanel.tsx b/components/session/SettingsPanel.tsx index 7738c89..f6e59e7 100644 --- a/components/session/SettingsPanel.tsx +++ b/components/session/SettingsPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' - +import { useTheme } from 'next-themes' import { Globe, Lock } from 'lucide-react' export interface TimerDurations { @@ -80,6 +80,7 @@ function ToggleRow({ label, checked, onToggle }: { label: string; checked: boole export function SettingsPanel({ settings, onApply, disabled, isWatcher, isPublic, onTogglePublic }: SettingsPanelProps) { const [local, setLocal] = useState(settings) + const { resolvedTheme, setTheme } = useTheme() const setDuration = (key: keyof TimerDurations) => (v: number) => setLocal(prev => ({ ...prev, durations: { ...prev.durations, [key]: v } })) @@ -155,8 +156,6 @@ export function SettingsPanel({ settings, onApply, disabled, isWatcher, isPublic >
- - {isPublic ? : } {isPublic ? : } {isPublic ? 'Public room' : 'Private room'} @@ -177,6 +176,35 @@ export function SettingsPanel({ settings, onApply, disabled, isWatcher, isPublic )} +

+ Appearance +

+ +
+ {(['light', 'dark'] as const).map((t) => { + const active = resolvedTheme === t + return ( + + ) + })} +
+
- ← Back to PomodoroJam + ← Back to Bonfire
) diff --git a/hooks/useBonfireState.ts b/hooks/useBonfireState.ts new file mode 100644 index 0000000..739ed7f --- /dev/null +++ b/hooks/useBonfireState.ts @@ -0,0 +1,186 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { TimerStatus, TimerMode } from '@/types' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BonfireInput { + status: TimerStatus + mode: TimerMode + focusCount: number + participantCount: number + accountabilityMode?: boolean +} + +export interface BonfireOutput { + targetIntensity: number + flameLabel: string + isSurging: boolean + tabHiddenMs: number +} + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +function baseIntensityFor(status: TimerStatus, mode: TimerMode): number { + if (status === 'running' && mode === 'focus') return 0.28 // starts small, grows via surges + if (status === 'running' && mode === 'short') return 0.08 // calm embers — earned rest + if (status === 'running' && mode === 'long') return 0.04 // near-dormant — deep rest + if (status === 'paused') return 0.10 + if (status === 'finished') return 0.05 + return 0.02 // idle +} + +function flameLabelFor( + status: TimerStatus, + mode: TimerMode, + isSurging: boolean, + intensity: number, +): string { + if (status === 'idle') return 'DORMANT' + if (status === 'paused') return 'FADING' + if (status === 'finished') return 'FADING' + if (status === 'running' && mode === 'short') return 'RESTING' + if (status === 'running' && mode === 'long') return 'COOLING' + if (status === 'running' && mode === 'focus') { + if (isSurging || intensity >= 0.9) return 'BLAZING' + return 'THRIVING' + } + return '' +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Maps timer state + presence into bonfire visual state. + * + * MERN comparison: this is like a Redux selector that derives UI state + * from multiple inputs — but as a React hook with side-effect-driven + * transient boosts (surges, dips) that self-reset via setTimeout. + * + * Key design: targetIntensity is a step-function that changes on discrete + * events. The Three.js component lerps toward it smoothly in useFrame — + * so React never has to re-render 60× per second. + * + * Interview angle: "Separate the discrete event logic (React state + hooks) + * from the continuous animation (useFrame refs). Never call setState inside + * an animation loop — it forces a React reconciliation every frame." + */ +export function useBonfireState({ + status, + mode, + focusCount, + participantCount, + accountabilityMode = false, +}: BonfireInput): BonfireOutput { + const [intensityBoost, setIntensityBoost] = useState(0) + const [isSurging, setIsSurging] = useState(false) + const [accountabilityDecay, setAccountabilityDecay] = useState(0) + const [tabHiddenMs, setTabHiddenMs] = useState(0) + + const prevFocusCountRef = useRef(focusCount) + const prevParticipantRef = useRef(participantCount) + const tabHiddenAtRef = useRef(null) + const surgeTimerRef = useRef | null>(null) + const returnSurgeTimerRef = useRef | null>(null) + const accountabilityRef = useRef | null>(null) + + // ── Pomodoro complete → surge ───────────────────────────────────────────── + useEffect(() => { + if (focusCount > prevFocusCountRef.current) { + setIsSurging(true) + setIntensityBoost(0.35) + if (surgeTimerRef.current) clearTimeout(surgeTimerRef.current) + surgeTimerRef.current = setTimeout(() => { + setIsSurging(false) + setIntensityBoost(0) + }, 2000) + } + prevFocusCountRef.current = focusCount + }, [focusCount]) + + // ── Participant join / leave → brief intensity delta ────────────────────── + useEffect(() => { + const prev = prevParticipantRef.current + prevParticipantRef.current = participantCount + + if (participantCount > prev) { + // Someone joined: brief surge + setIntensityBoost(b => Math.min(b + 0.12, 0.35)) + const t = setTimeout(() => setIntensityBoost(b => Math.max(b - 0.12, 0)), 1500) + return () => clearTimeout(t) + } + if (participantCount < prev && participantCount >= 1) { + // Someone left: brief dip then recover + setIntensityBoost(b => b - 0.08) + const t = setTimeout(() => setIntensityBoost(b => b + 0.08), 1500) + return () => clearTimeout(t) + } + }, [participantCount]) + + // ── Tab visibility ──────────────────────────────────────────────────────── + // Day 14: Page Visibility API — track hidden time, fire return surge, + // optional accountability mode decay. + // + // Why NOT kill the fire on hide: users do their actual work in other + // apps/tabs. Hiding ≠ slacking. Only track + reward return. + useEffect(() => { + function onVisibilityChange() { + if (document.visibilityState === 'hidden') { + tabHiddenAtRef.current = Date.now() + + // Accountability mode: gradual decay while away + if (accountabilityMode && status === 'running' && mode === 'focus') { + accountabilityRef.current = setInterval(() => { + setAccountabilityDecay(d => Math.min(d + 0.008, 0.55)) + }, 1000) + } + } else { + // Tab returned + if (tabHiddenAtRef.current !== null) { + setTabHiddenMs(ms => ms + (Date.now() - (tabHiddenAtRef.current as number))) + tabHiddenAtRef.current = null + } + if (accountabilityRef.current) { + clearInterval(accountabilityRef.current) + accountabilityRef.current = null + } + // Always recover from any accountability decay on return + setAccountabilityDecay(0) + + // Return surge — reward coming back to the tab + if (status === 'running') { + setIntensityBoost(b => b + 0.15) + if (returnSurgeTimerRef.current) clearTimeout(returnSurgeTimerRef.current) + returnSurgeTimerRef.current = setTimeout(() => setIntensityBoost(b => Math.max(b - 0.15, 0)), 1500) + } + } + } + + document.addEventListener('visibilitychange', onVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + if (accountabilityRef.current) clearInterval(accountabilityRef.current) + if (returnSurgeTimerRef.current) clearTimeout(returnSurgeTimerRef.current) + } + }, [accountabilityMode, status, mode]) + + // Reset hidden time counter when session resets to idle + useEffect(() => { + if (status === 'idle') setTabHiddenMs(0) + }, [status]) + + // ── Derive final intensity ──────────────────────────────────────────────── + const base = baseIntensityFor(status, mode) + const participantBonus = Math.min((participantCount - 1) * 0.08, 0.24) + const targetIntensity = Math.max(0, Math.min(1, + base + participantBonus + intensityBoost - accountabilityDecay + )) + + return { + targetIntensity, + flameLabel: flameLabelFor(status, mode, isSurging, targetIntensity), + isSurging, + tabHiddenMs, + } +} diff --git a/hooks/useSession.ts b/hooks/useSession.ts index 500adee..185f66f 100644 --- a/hooks/useSession.ts +++ b/hooks/useSession.ts @@ -63,6 +63,25 @@ export function useSession({ joined_at: joinedAtRef.current, }) }, [isHost, username]) + + // Re-track presence when the tab becomes visible again after being hidden. + // Browser JS-timer throttling can kill the Supabase heartbeat while the tab is + // hidden, causing a disconnect. Re-tracking immediately on focus ensures the + // channel reconnects and cancels any pending leave timer quickly. + useEffect(() => { + function onVisible() { + if (document.visibilityState === 'visible' && channelRef.current) { + channelRef.current.track({ + username: usernameRef.current ?? null, + avatar_url: avatarUrlRef.current ?? null, + is_host: isHostRef.current, + joined_at: joinedAtRef.current, + }) + } + } + document.addEventListener('visibilitychange', onVisible) + return () => document.removeEventListener('visibilitychange', onVisible) + }, []) const pendingLeaveTimers = useRef>>(new Map()) const timerCallbacksRef = useRef void>>(new Set()) const shareLockCallbacksRef = useRef void>>(new Set()) @@ -164,11 +183,11 @@ export function useSession({ ? channel.presenceState<{ username?: string | null }>()[key]?.[0] : null const leftUsername = leaving?.username ?? null - // Debounce leave for other participants — 15s grace period avoids false - // "X left" messages and participant list flicker caused by tab minimize / - // app switch / brief disconnects. Participant stays visible in the list - // until the grace window expires. If they rejoin within 15s the timer is - // cancelled above and they never disappear. + // 5-minute grace period — Supabase heartbeat gets throttled when a tab is + // hidden (browser JS timer throttling), causing a disconnect after ~30–60s. + // We wait 300s before treating this as a real leave so that users switching + // tabs to do their actual work are never shown as "left" to others. + // If they rejoin within 300s the pending timer is cancelled (see join handler above). if (key !== effectiveIdRef.current) { const existing = pendingLeaveTimers.current.get(key) if (existing !== undefined) clearTimeout(existing) @@ -176,7 +195,7 @@ export function useSession({ pendingLeaveTimers.current.delete(key) leaveCallbacksRef.current.forEach(cb => cb(leftUsername)) setParticipants((prev) => prev.filter((p) => p.user_id !== key)) - }, 15_000) + }, 300_000) pendingLeaveTimers.current.set(key, timer) } else { setParticipants((prev) => prev.filter((p) => p.user_id !== key)) diff --git a/hooks/useTimer.ts b/hooks/useTimer.ts index ceb17e4..b48344b 100644 --- a/hooks/useTimer.ts +++ b/hooks/useTimer.ts @@ -196,19 +196,19 @@ export function useTimer({ const secs = String(timeLeft % 60).padStart(2, '0') const modeLabel: Record = { - focus: '🍅 Focus', - short: '☕ Short Break', - long: '🎉 Long Break', + focus: 'Focus', + short: 'Short Break', + long: 'Long Break', } if (timeLeft > 0 && timerState.status !== 'idle') { - document.title = `${mins}:${secs} ${modeLabel[timerState.mode]} | PomodoroJam` + document.title = `${mins}:${secs} ${modeLabel[timerState.mode]} | Bonfire` } else { - document.title = 'PomodoroJam 🍅' + document.title = 'Bonfire' } return () => { - document.title = 'PomodoroJam 🍅' + document.title = 'Bonfire' } }, [timeLeft, timerState.mode, timerState.status]) @@ -225,7 +225,7 @@ export function useTimer({ useEffect(() => { return () => { resetFavicon() - document.title = 'PomodoroJam 🍅' + document.title = 'Bonfire' } }, []) diff --git a/lib/ambient.ts b/lib/ambient.ts index 3324568..2f15307 100644 --- a/lib/ambient.ts +++ b/lib/ambient.ts @@ -2,11 +2,11 @@ export type AmbientType = 'brown' | 'white' | 'pink' | 'rain' -export const AMBIENT_SOUNDS: { type: AmbientType; label: string; emoji: string; description: string }[] = [ - { type: 'brown', label: 'Brown', emoji: '🌊', description: 'Deep, warm rumble — like a distant waterfall' }, - { type: 'pink', label: 'Pink', emoji: '🌸', description: 'Balanced noise — the most natural-sounding' }, - { type: 'white', label: 'White', emoji: '📻', description: 'Classic white noise — blocks all distractions' }, - { type: 'rain', label: 'Rain', emoji: '🌧️', description: 'Rain-like texture — cozy and grounding' }, +export const AMBIENT_SOUNDS: { type: AmbientType; label: string; description: string }[] = [ + { type: 'brown', label: 'Brown', description: 'Deep, warm rumble. Like a distant waterfall.' }, + { type: 'pink', label: 'Pink', description: 'Balanced noise. The most natural-sounding.' }, + { type: 'white', label: 'White', description: 'Classic white noise. Blocks all distractions.' }, + { type: 'rain', label: 'Rain', description: 'Rain-like texture. Cozy and grounding.' }, ] export class AmbientPlayer { diff --git a/lib/favicon.ts b/lib/favicon.ts index 7fae0cd..e5b1f52 100644 --- a/lib/favicon.ts +++ b/lib/favicon.ts @@ -61,21 +61,12 @@ export function updateFavicon(timeLeft: number, mode: string): void { ctx.lineWidth = 1.5 ctx.stroke() - // Text + // Text — show only minutes for readability (e.g. "25", "4", "0") ctx.fillStyle = colors.text ctx.textAlign = 'center' ctx.textBaseline = 'middle' - - if (mins >= 10) { - ctx.font = 'bold 14px monospace' - ctx.fillText(String(mins), 16, 16) - } else if (mins > 0) { - ctx.font = 'bold 10px monospace' - ctx.fillText(`${mins}:${String(secs).padStart(2, '0')}`, 16, 16) - } else { - ctx.font = 'bold 11px monospace' - ctx.fillText(`:${String(secs).padStart(2, '0')}`, 16, 16) - } + ctx.font = mins >= 10 ? 'bold 15px Arial, sans-serif' : 'bold 18px Arial, sans-serif' + ctx.fillText(String(mins), 16, 16) const link = getLink() link.href = canvas.toDataURL('image/png') @@ -90,25 +81,28 @@ export function resetFavicon(): void { ctx.clearRect(0, 0, FAVICON_SIZE, FAVICON_SIZE) - // Red circle background + // Radial gradient: dark center → deep red edge + const grad = ctx.createRadialGradient(16, 16, 2, 16, 16, 15) + grad.addColorStop(0, '#1a0000') + grad.addColorStop(1, '#c0200f') + ctx.beginPath() ctx.arc(16, 16, 15, 0, 2 * Math.PI) - ctx.fillStyle = '#e8472a' + ctx.fillStyle = grad ctx.fill() - // Thin ring + // Shiny highlight ring ctx.beginPath() ctx.arc(16, 16, 14, 0, 2 * Math.PI) - ctx.strokeStyle = '#c23820' - ctx.lineWidth = 1.5 + ctx.strokeStyle = 'rgba(255, 100, 50, 0.5)' + ctx.lineWidth = 1 ctx.stroke() - // Tomato emoji — canvas uses system emoji fonts, always renders correctly - ctx.font = '18px serif' + // Fire emoji — large, centered + ctx.font = '20px serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText('🍅', 16, 17) + ctx.fillText('🔥', 16, 16) - const link = getLink() - link.href = canvas.toDataURL('image/png') + getLink().href = canvas.toDataURL('image/png') } diff --git a/lib/roomName.ts b/lib/roomName.ts index c1e411a..3005744 100644 --- a/lib/roomName.ts +++ b/lib/roomName.ts @@ -16,7 +16,7 @@ export function generateRoomName(): string { const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] const num = Math.floor(Math.random() * 900) + 100 // 100–999 - return `${adj}-${noun}-${num}` + return `${adj} ${noun} ${num}` } // Capitalises first letter of each word @@ -24,6 +24,12 @@ function capitalise(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) } +export function generateAnonName(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] + return `${capitalise(adj)} ${capitalise(noun)}` +} + export function generateUsername(): string { const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] diff --git a/lib/session.ts b/lib/session.ts index 2ef378b..70643ae 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -14,7 +14,7 @@ export function generateSessionId(): string { export function formatSessionUrl(sessionId: string): string { const appUrl = process.env.NEXT_PUBLIC_APP_URL || - (typeof window !== 'undefined' ? window.location.origin : 'https://pomodoro-jam.vercel.app') + (typeof window !== 'undefined' ? window.location.origin : 'https://bonfirefocus.vercel.app') return `${appUrl}/session/${sessionId}` } diff --git a/lib/share.ts b/lib/share.ts index da809f7..8f17e0f 100644 --- a/lib/share.ts +++ b/lib/share.ts @@ -6,12 +6,12 @@ import { formatSessionUrl } from './session' export function buildTwitterShareUrl(sessionId: string, sessionName?: string | null): string { const sessionUrl = formatSessionUrl(sessionId) const text = sessionName - ? `Join my "${sessionName}" Pomodoro session on PomodoroJam! Stay focused together.` - : 'Join my Pomodoro session on PomodoroJam! Stay focused together.' + ? `Join my "${sessionName}" Pomodoro session on Bonfire! Stay focused together.` + : 'Join my Pomodoro session on Bonfire! Stay focused together.' const params = new URLSearchParams({ text, url: sessionUrl, - hashtags: 'PomodoroJam,Focus,Productivity', + hashtags: 'Bonfire,Focus,Productivity', }) return `https://twitter.com/intent/tweet?${params.toString()}` } @@ -54,7 +54,7 @@ export async function nativeShare(sessionId: string, sessionName?: string | null return false } const url = formatSessionUrl(sessionId) - const title = sessionName ? `PomodoroJam: ${sessionName}` : 'PomodoroJam Session' + const title = sessionName ? `Bonfire: ${sessionName}` : 'Bonfire Session' try { await navigator.share({ title, diff --git a/next.config.js b/next.config.js index 47e150d..d5467c3 100644 --- a/next.config.js +++ b/next.config.js @@ -29,7 +29,7 @@ const nextConfig = { { key: 'X-Frame-Options', value: 'DENY' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - { key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''}; style-src 'self' 'unsafe-inline'; connect-src 'self' *.supabase.co wss://*.supabase.co; img-src 'self' data: blob: *.supabase.co *.githubusercontent.com lh3.googleusercontent.com; font-src 'self' data: fonts.gstatic.com fonts.googleapis.com;` }, + { key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''} va.vercel-scripts.com; style-src 'self' 'unsafe-inline'; connect-src 'self' *.supabase.co wss://*.supabase.co vitals.vercel-insights.com; img-src 'self' data: blob: *.supabase.co *.githubusercontent.com lh3.googleusercontent.com; font-src 'self' data: fonts.gstatic.com fonts.googleapis.com;` }, ], }, ] diff --git a/package-lock.json b/package-lock.json index 588ce99..72dc8fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { - "name": "pomodorojam", + "name": "bonfire", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pomodorojam", + "name": "bonfire", "version": "0.1.0", "dependencies": { + "@react-three/fiber": "^8.18.0", "@supabase/ssr": "^0.5.0", "@supabase/supabase-js": "^2.45.0", "@vercel/analytics": "^2.0.1", @@ -20,6 +21,7 @@ "react-dom": "^18", "react-easy-crop": "^5.5.7", "tailwind-merge": "^2.3.0", + "three": "^0.184.0", "zod": "^3.23.0" }, "devDependencies": { @@ -28,6 +30,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/three": "^0.184.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.0.1", "eslint": "^8", @@ -141,7 +144,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -300,6 +302,13 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -781,6 +790,101 @@ "node": ">=14" } }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz", @@ -1306,6 +1410,13 @@ } } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1382,14 +1493,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1406,6 +1515,50 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz", + "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2649,6 +2802,50 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2985,7 +3182,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4496,6 +4692,26 @@ "node": ">=20.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5051,6 +5267,27 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", @@ -5634,6 +5871,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6564,6 +6808,46 @@ "dev": true, "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7473,6 +7757,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -7558,6 +7851,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", diff --git a/package.json b/package.json index 3f1636e..d669626 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pomodorojam", + "name": "bonfire", "version": "0.1.0", "private": true, "scripts": { @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "@react-three/fiber": "^8.18.0", "@supabase/ssr": "^0.5.0", "@supabase/supabase-js": "^2.45.0", "@vercel/analytics": "^2.0.1", @@ -24,6 +25,7 @@ "react-dom": "^18", "react-easy-crop": "^5.5.7", "tailwind-merge": "^2.3.0", + "three": "^0.184.0", "zod": "^3.23.0" }, "devDependencies": { @@ -40,6 +42,7 @@ "postcss": "^8", "tailwindcss": "^3.4.0", "typescript": "^5", - "vitest": "^4.1.4" + "vitest": "^4.1.4", + "@types/three": "^0.184.0" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 1d03746..11c9b9d 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png index d8026e9..958ff4b 100644 Binary files a/public/favicon-32.png and b/public/favicon-32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..958ff4b Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon-192.png b/public/icon-192.png index b47600f..1d164b4 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png index fcac852..2433f91 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json index a09f40f..dae6f15 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { - "name": "PomodoroJam", - "short_name": "PomodoroJam", - "description": "A shared Pomodoro timer for friends. Start a session, share the link, focus in sync.", + "name": "Bonfire", + "short_name": "Bonfire", + "description": "A shared focus timer for friends. Start a room, share the link, focus in sync.", "start_url": "/", "display": "standalone", "background_color": "#0F0F0D", diff --git a/supabase/migrations/001_init.sql b/supabase/migrations/001_init.sql index 340bbbc..d4702b9 100644 --- a/supabase/migrations/001_init.sql +++ b/supabase/migrations/001_init.sql @@ -1,5 +1,5 @@ -- ============================================================ --- PomodoroJam — Initial Schema +-- Bonfire — Initial Schema -- ============================================================ -- Profiles (extends Supabase auth.users) diff --git a/tailwind.config.ts b/tailwind.config.ts index e32c3ac..27dde4f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,7 +10,7 @@ const config: Config = { extend: { fontFamily: { sans: ['var(--font-dm-sans)', 'system-ui', 'sans-serif'], - display: ['var(--font-syne)', 'sans-serif'], + display: ['var(--font-display)', 'sans-serif'], mono: ['var(--font-mono)', 'monospace'], }, colors: {