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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Raidy is a raid scheduling project for Discord statics: the web console is where everyone plans, and Discord carries the announcements. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL.

**Status:** `v0.0.19` is an early development snapshot.
**Status:** `v0.0.20` is an early development snapshot.

```text
backend/ Go service, Discord interactions, web API, PostgreSQL migrations
Expand Down
14 changes: 14 additions & 0 deletions web/e2e/product.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,17 @@ test("the page never scrolls sideways on a phone", async ({ browser }) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
});

test("the theme switch overrides the system and carries over to the demo", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
const html = page.locator("html");
await expect(html).toHaveClass(/dark/);
await page.getByRole("radio", { name: "Light theme" }).click();
await expect(html).not.toHaveClass(/dark/);
await page.reload();
await expect(html).not.toHaveClass(/dark/);
await page.getByRole("link", { name: "Try the demo" }).click();
await expect(page.getByRole("table", { name: /availability/i })).toBeVisible({ timeout: 30_000 });
await expect(html).not.toHaveClass(/dark/);
});
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.19",
"version": "0.0.20",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
17 changes: 14 additions & 3 deletions web/product/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
(() => {
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
document.querySelector('meta[name="theme-color"]').content = dark ? "#121110" : "#fafaf9";
let preference = "system";
try {
preference = localStorage.getItem("raidy-theme") || "system";
} catch {}
const theme =
preference === "system"
? matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: preference;
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.querySelector('meta[name="theme-color"]').content = theme === "dark" ? "#121110" : "#fafaf9";
})();
</script>
</head>
Expand Down
30 changes: 2 additions & 28 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SchedulePanel } from "@/features/schedule/schedule-panel";
import { TeamPanel } from "@/features/team/team-panel";
import { message } from "@/lib/format";
import { isDemo, productURL, teamURL } from "@/lib/paths";
import { applyTheme, type Theme, type ThemePreference } from "@/lib/theme";
import { useTheme } from "@/lib/theme";
import { cn } from "@/lib/utils";

type Tab = "overview" | "schedule" | "team";
Expand All @@ -33,11 +33,7 @@ export default function App() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
const saved = document.documentElement.dataset.themePreference;
return saved === "light" || saved === "dark" ? saved : "system";
});
const [resolvedTheme, setResolvedTheme] = useState<Theme>("light");
const { preference: themePreference, resolved: resolvedTheme, choose: chooseTheme } = useTheme();

async function loadData(preferred?: number) {
const [nextTeams, nextGuilds] = await Promise.all([api<Team[]>("/api/teams"), api<Guild[]>("/api/guilds")]);
Expand Down Expand Up @@ -68,18 +64,6 @@ export default function App() {
})();
}, []);

useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const update = () => {
const theme = themePreference === "system" ? (media.matches ? "dark" : "light") : themePreference;
applyTheme(theme);
setResolvedTheme(theme);
};
update();
if (themePreference === "system") media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [themePreference]);

const selected = teams.find((team) => team.id === teamID) ?? null;

function selectTeam(id: number) {
Expand All @@ -100,16 +84,6 @@ export default function App() {
location.assign(isDemo ? productURL : "/");
}

function chooseTheme(next: ThemePreference) {
document.documentElement.dataset.themePreference = next;
try {
localStorage.setItem("raidy-theme", next);
} catch {
/* Theme still applies for this page. */
}
setThemePreference(next);
}

const toaster = <Toaster theme={resolvedTheme} position="bottom-right" toastOptions={{ className: "font-sans" }} />;

if (loading) return <Loading />;
Expand Down
38 changes: 38 additions & 0 deletions web/src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";

export type Theme = "light" | "dark";

export type ThemePreference = Theme | "system";
Expand All @@ -9,3 +11,39 @@ export function applyTheme(theme: Theme) {
.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
?.setAttribute("content", theme === "dark" ? "#121110" : "#fafaf9");
}

/**
* The viewer's theme choice (System, Light or Dark), saved per browser and
* shared by every page on the site, and the theme it resolves to.
*/
export function useTheme() {
const [preference, setPreference] = useState<ThemePreference>(() => {
const saved = document.documentElement.dataset.themePreference;
return saved === "light" || saved === "dark" ? saved : "system";
});
const [resolved, setResolved] = useState<Theme>("light");

useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const update = () => {
const theme = preference === "system" ? (media.matches ? "dark" : "light") : preference;
applyTheme(theme);
setResolved(theme);
};
update();
if (preference === "system") media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [preference]);

function choose(next: ThemePreference) {
document.documentElement.dataset.themePreference = next;
try {
localStorage.setItem("raidy-theme", next);
} catch {
/* Theme still applies for this page. */
}
setPreference(next);
}

return { preference, resolved, choose };
}
48 changes: 32 additions & 16 deletions web/src/product/product-page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { CalendarClock, CalendarSync, Globe, MessageSquareText, MousePointerClick, ShieldCheck } from "lucide-react";
import { Wordmark } from "@/components/common";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/user-menu";
import { type Theme, useTheme } from "@/lib/theme";
import { HeroDemo } from "./hero-demo";

const github = "https://github.com/joshchen-dev/raidy";
Expand Down Expand Up @@ -64,6 +67,7 @@ const questions = [
];

export function ProductPage() {
const theme = useTheme();
return (
<div className="min-h-screen bg-background text-foreground">
<header className="mx-auto flex h-16 max-w-6xl items-center gap-6 px-4 sm:px-6">
Expand All @@ -81,9 +85,14 @@ export function ProductPage() {
FAQ
</a>
</nav>
<Button asChild size="sm" className="ml-auto">
<a href="demo/">Try the demo</a>
</Button>
<div className="ml-auto flex items-center gap-3">
<div className="hidden sm:block">
<ThemeToggle value={theme.preference} onChange={theme.choose} />
</div>
<Button asChild size="sm">
<a href="demo/">Try the demo</a>
</Button>
</div>
</header>

<main>
Expand Down Expand Up @@ -145,8 +154,12 @@ export function ProductPage() {
Inside the app
</h2>
<div className="mt-12 grid items-end gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
<Screen name="schedule" alt="The schedule editor with raid days, times and a preview of the first week" />
<Screen name="mobile" alt="The availability grid on a phone" narrow />
<Screen
theme={theme.resolved}
name="schedule"
alt="The schedule editor with raid days, times and a preview of the first week"
/>
<Screen theme={theme.resolved} name="mobile" alt="The availability grid on a phone" narrow />
</div>
</div>
</section>
Expand Down Expand Up @@ -194,6 +207,9 @@ export function ProductPage() {
</main>

<footer className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-8 text-sm text-muted-foreground sm:px-6">
<div className="w-full sm:hidden">
<ThemeToggle value={theme.preference} onChange={theme.choose} />
</div>
<Wordmark />
<a className="hover:text-foreground" href={github}>
GitHub
Expand All @@ -206,17 +222,17 @@ export function ProductPage() {
);
}

/** A screenshot of the real app, in the viewer's color scheme. */
function Screen({ name, alt, narrow = false }: { name: string; alt: string; narrow?: boolean }) {
/** A screenshot of the real app, in the page's current theme. */
function Screen({ theme, name, alt, narrow = false }: { theme: Theme; name: string; alt: string; narrow?: boolean }) {
return (
<picture className={narrow ? "mx-auto block w-full max-w-[280px]" : "block"}>
<source srcSet={`screens/${name}-dark.png`} media="(prefers-color-scheme: dark)" />
<img
src={`screens/${name}-light.png`}
alt={alt}
loading="lazy"
className="w-full rounded-lg border bg-card shadow-[0_24px_48px_-24px_rgb(28_25_23/0.25)]"
/>
</picture>
<img
src={`screens/${name}-${theme}.png`}
alt={alt}
loading="lazy"
className={cn(
"w-full rounded-lg border bg-card shadow-[0_24px_48px_-24px_rgb(28_25_23/0.25)]",
narrow && "mx-auto max-w-[280px]"
)}
/>
);
}
Loading