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.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;
+ }
}
/*