diff --git a/postcss.config.cjs b/postcss.config.cjs index e564072..20efc59 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -1,5 +1,7 @@ module.exports = { plugins: { - '@tailwindcss/postcss': {}, + '@tailwindcss/postcss': { + darkMode: 'class', + }, }, }; diff --git a/src/app/(content)/recently-played/RecentlyPlayed.tsx b/src/app/(content)/recently-played/RecentlyPlayed.tsx index b8f5934..e08e508 100644 --- a/src/app/(content)/recently-played/RecentlyPlayed.tsx +++ b/src/app/(content)/recently-played/RecentlyPlayed.tsx @@ -40,12 +40,12 @@ const fetchRecentlyPlayed = async (queryClient: QueryClient) => { const LoadingSkeleton = () => (
{Array.from({ length: 8 }).map((_, i) => ( -
-
-
+
+
+
-
-
+
+
))} @@ -58,11 +58,11 @@ const EmptyState = () => ( animate={{ opacity: 1, y: 0 }} className="flex flex-col items-center justify-center py-16 text-center" > -
- +
+
-

No recent activity

-

+

No recent activity

+

Tracks will appear here as people listen to shows across the Relisten community.

@@ -79,7 +79,7 @@ export default function RecentlyPlayed() { const tracks = query.data ? uniqBy(query.data, keyFn).slice(0, 40) : []; return ( -
+
{/* Header */}
-
- +
+
-

Recently Played

+

Recently Played

-

+

This is what people are listening to right now - join 'em.

diff --git a/src/app/(embed)/layout.tsx b/src/app/(embed)/layout.tsx index ad2a577..14527e0 100644 --- a/src/app/(embed)/layout.tsx +++ b/src/app/(embed)/layout.tsx @@ -15,14 +15,14 @@ export default async function EmbedLayout({ children }: { children: ReactNode }) ); return ( - -
+ +
POWERED BY{' '} RELISTEN.NET {' '} @@ -31,12 +31,12 @@ export default async function EmbedLayout({ children }: { children: ReactNode }) href="https://phish.in" target="_blank" rel="noopener noreferrer" - className="underline hover:text-amber-700" + className="underline hover:text-amber-700 dark:hover:text-amber-200" > PHISH.IN
-
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6caf869..a8adfa8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import NextTopLoader from 'nextjs-toploader'; import dns from 'node:dns'; import React from 'react'; import Providers from './Providers'; +import { getTheme } from '@/lib/theme'; // https://github.com/node-fetch/node-fetch/issues/1624#issuecomment-1407717012 dns.setDefaultResultOrder('ipv4first'); @@ -13,9 +14,18 @@ import '../styles/globals.css'; // TODO: figure out if we don't need any weights const font = Roboto({ subsets: ['latin'], weight: ['400', '500', '700', '900'] }); -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const theme = await getTheme(); + + // Determine class names: if theme is explicitly set, use it; otherwise let CSS media query decide + const htmlClasses = theme === 'dark' + ? 'dark bg-background' + : theme === 'light' + ? 'light bg-background' + : 'bg-background'; + return ( - + diff --git a/src/components/ColumnWithToggleControls.tsx b/src/components/ColumnWithToggleControls.tsx index 1f90020..c6972a8 100644 --- a/src/components/ColumnWithToggleControls.tsx +++ b/src/components/ColumnWithToggleControls.tsx @@ -84,7 +84,7 @@ const ColumnWithToggleControls = ({ )} {filteredCount !== undefined && totalCount !== undefined && filteredCount < totalCount && ( -
+
{filteredCount === 0 ? ( <> All {simplePluralize('row', hiddenRows)} are hidden by filters.{' '} diff --git a/src/components/LiveTrack.tsx b/src/components/LiveTrack.tsx index 374ee6a..9f0d974 100644 --- a/src/components/LiveTrack.tsx +++ b/src/components/LiveTrack.tsx @@ -87,8 +87,8 @@ export default function LiveTrack({ damping: 20, stiffness: 300, }} - className={`relative h-full rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-all duration-200 hover:border-gray-200 hover:shadow-lg ${ - isLastSeen ? 'border-green-200 ring-2 ring-green-100' : '' + className={`relative h-full rounded-xl border border-border-light bg-surface p-4 shadow-sm transition-all duration-200 hover:border-border hover:shadow-lg ${ + isLastSeen ? 'border-green-200 ring-2 ring-green-100 dark:border-green-700 dark:ring-green-900' : '' }`} data-is-last-seen={isLastSeen} > @@ -99,12 +99,12 @@ export default function LiveTrack({
{/* Track title */} -
+
{track.track.title}
{/* Artist name */} -
{track.source.artist?.name}
+
{track.source.artist?.name}
{/* Venue and date info */}
diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx index b79e1d4..50e29a6 100644 --- a/src/components/Menu.tsx +++ b/src/components/Menu.tsx @@ -1,10 +1,11 @@ import Column from './Column'; import Row from './Row'; +import ThemeToggle from './ThemeToggle'; // TODO: replace this with shadcn/radix const Menu = () => ( -
+
Home About Today @@ -12,6 +13,9 @@ const Menu = () => ( Sonos App Chat +
+ +
); diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 3fe2de5..3903333 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -6,6 +6,7 @@ import * as Popover from '@/components/Popover'; import RelistenAPI from '@/lib/RelistenAPI'; import MainNavHeader from './MainNavHeader'; import AndroidUpgradeNotification from './AndroidUpgradeNotification'; +import ThemeToggle from './ThemeToggle'; import { MenuIcon } from 'lucide-react'; import { headers } from 'next/headers'; import parser from 'ua-parser-js'; @@ -39,7 +40,7 @@ export default async function NavBar() { return ( <> -
+
+
+ +
{isAndroid && } diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 2a95198..16e2bcc 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -113,7 +113,7 @@ const Player = ({ artistSlugsToName }: Props) => {
{durationToHHMMSS(playback.activeTrack.currentTime)}
-
+
{activeTrack.title} {false && ( @@ -149,16 +149,16 @@ const Player = ({ artistSlugsToName }: Props) => {
@@ -167,11 +167,11 @@ const Player = ({ artistSlugsToName }: Props) => { {activeTrack && (
( {children} diff --git a/src/components/RowLoading.tsx b/src/components/RowLoading.tsx index 24ee724..107390d 100644 --- a/src/components/RowLoading.tsx +++ b/src/components/RowLoading.tsx @@ -2,11 +2,11 @@ import type { JSX } from 'react'; const RowLoading = (): JSX.Element => (
-
+
-
-
+
+
); diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..8f010ee --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Moon, Sun } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { setTheme as setThemeCookie } from '@/lib/theme'; + +export default function ThemeToggle() { + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + + useEffect(() => { + // Sync with current DOM state (set server-side) + const isDark = document.documentElement.classList.contains('dark'); + setTheme(isDark ? 'dark' : 'light'); + }, []); + + const toggleTheme = async () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + + // Update DOM immediately for instant feedback + if (newTheme === 'dark') { + document.documentElement.classList.add('dark'); + document.documentElement.classList.remove('light'); + } else { + document.documentElement.classList.add('light'); + document.documentElement.classList.remove('dark'); + } + + // Update cookie for persistence across navigation + await setThemeCookie(newTheme); + }; + + return ( + + ); +} diff --git a/src/components/TodayTrack.tsx b/src/components/TodayTrack.tsx index af74cd7..2c6c988 100644 --- a/src/components/TodayTrack.tsx +++ b/src/components/TodayTrack.tsx @@ -14,13 +14,13 @@ export default ({ day }: { day: Day }) => { return ( - +
{day.display_date}
-
+
{day.venue?.name || 'Unknown Venue'}
{day.venue?.location && ( diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..00ce8d0 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,28 @@ +'use server'; + +import { cookies } from 'next/headers'; + +const THEME_COOKIE_NAME = 'relisten_theme'; + +export type Theme = 'light' | 'dark'; + +export async function getTheme(): Promise { + const cookieStore = await cookies(); + const themeCookie = cookieStore.get(THEME_COOKIE_NAME); + return (themeCookie?.value as Theme) || null; +} + +export async function setTheme(theme: Theme) { + // Runtime validation to ensure theme is valid and fallback to 'light' if invalid + if (theme !== 'light' && theme !== 'dark') { + theme = 'light'; + console.error(`Invalid theme: ${theme}. Theme must be 'light' or 'dark'.`); + } + + const cookieStore = await cookies(); + cookieStore.set(THEME_COOKIE_NAME, theme, { + path: '/', + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: 'lax', + }); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 5866219..a8d2d9f 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -17,13 +17,61 @@ --color-relisten-900: #001D24; --color-relisten-950: #001114; - /* Basic foreground colors */ + /* Light mode colors (default) */ --color-foreground: #333; --color-foreground-muted: var(--color-gray-500); + --color-background: #fefefe; + --color-background-muted: #f3f4f6; + --color-border: #aeaeae; + --color-border-light: var(--color-gray-100); + --color-surface: white; + --color-progress-bg: #bcbcbc; + --color-progress-fg: #707070; + --color-skeleton: #dddddd; +} - /* Basic background colors */ +/* Explicit light mode */ +html:not(.dark) { + --color-foreground: #333; + --color-foreground-muted: var(--color-gray-500); --color-background: #fefefe; --color-background-muted: #f3f4f6; + --color-border: #aeaeae; + --color-border-light: var(--color-gray-100); + --color-surface: white; + --color-progress-bg: #bcbcbc; + --color-progress-fg: #707070; + --color-skeleton: #dddddd; +} + +/* Dark mode - explicit class takes precedence */ +html.dark { + --color-foreground: #e5e5e5; + --color-foreground-muted: var(--color-gray-400); + --color-background: #0a0a0a; + --color-background-muted: #171717; + --color-border: #404040; + --color-border-light: var(--color-gray-800); + --color-surface: #1a1a1a; + --color-progress-bg: #404040; + --color-progress-fg: #a3a3a3; + --color-skeleton: #262626; +} + +/* Respect system preference when no explicit theme is set */ +@media (prefers-color-scheme: dark) { + html:not(.dark):not(.light) { + --color-foreground: #e5e5e5; + --color-foreground-muted: var(--color-gray-400); + --color-background: #0a0a0a; + --color-background-muted: #171717; + --color-border: #404040; + --color-border-light: var(--color-gray-800); + --color-surface: #1a1a1a; + --color-progress-bg: #404040; + --color-progress-fg: #a3a3a3; + --color-skeleton: #262626; + } } /*