sunset theme following
This commit is contained in:
+3
-1
@@ -85,7 +85,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
||||
|
||||
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
|
||||
class from the account theme (`auto` follows `prefers-color-scheme` with a
|
||||
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
|
||||
live media-query listener; `sunset` follows the browser's local time —
|
||||
light 07:00–19:00, dark otherwise — re-applied at each boundary via
|
||||
`lib/solarTheme.ts`). Accent: emerald; shapes: `rounded-2xl/3xl`
|
||||
everywhere; every surface needs a `dark:` variant. UI text is English only.
|
||||
|
||||
## Mobile
|
||||
|
||||
+15
-5
@@ -27,6 +27,7 @@ import { ReviewPage } from "./components/ReviewPage.tsx";
|
||||
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
|
||||
import { Toasts } from "./components/Toasts.tsx";
|
||||
import { pushToast } from "./lib/toasts.ts";
|
||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts";
|
||||
import { useT } from "./lib/i18n.ts";
|
||||
|
||||
function shareIdFromPath(): string | null {
|
||||
@@ -65,10 +66,7 @@ const MAX_RETRIES = 3;
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
const root = document.documentElement;
|
||||
const dark =
|
||||
theme === "dark" ||
|
||||
(theme !== "light" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const dark = resolveThemeDark(theme);
|
||||
root.classList.toggle("dark", dark);
|
||||
root.style.colorScheme = dark ? "dark" : "light";
|
||||
}
|
||||
@@ -272,8 +270,20 @@ export function App() {
|
||||
.catch(() => setMessages([]));
|
||||
}, [activeId]);
|
||||
|
||||
// Follow OS theme when account theme is auto
|
||||
// Follow OS theme when account theme is auto; follow local time (07:00–19:00)
|
||||
// when account theme is sunset — re-applied at each day/night boundary.
|
||||
useEffect(() => {
|
||||
if (user?.theme === "sunset") {
|
||||
let timer: number | undefined;
|
||||
const schedule = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
applyTheme("sunset");
|
||||
schedule();
|
||||
}, msUntilNextSolarSwitch());
|
||||
};
|
||||
schedule();
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
if (user?.theme !== "auto") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const h = () => applyTheme("auto");
|
||||
|
||||
@@ -8,13 +8,14 @@ import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
const root = document.documentElement;
|
||||
const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const dark = resolveThemeDark(theme);
|
||||
root.classList.toggle("dark", dark);
|
||||
root.style.colorScheme = dark ? "dark" : "light";
|
||||
}
|
||||
@@ -39,6 +40,7 @@ export function AdminCenter() {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
api.verify()
|
||||
.then((r) => {
|
||||
if (!r.user.isAdmin) {
|
||||
@@ -47,9 +49,19 @@ export function AdminCenter() {
|
||||
}
|
||||
setAllowed(true);
|
||||
applyTheme(r.user.theme);
|
||||
if (r.user.theme === "sunset") {
|
||||
const schedule = (): void => {
|
||||
timer = window.setTimeout(() => {
|
||||
applyTheme("sunset");
|
||||
schedule();
|
||||
}, msUntilNextSolarSwitch());
|
||||
};
|
||||
schedule();
|
||||
}
|
||||
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||
})
|
||||
.catch(() => setAllowed(false));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
|
||||
|
||||
@@ -459,6 +459,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: t("common.themeAuto") },
|
||||
{ value: "sunset", label: t("common.themeSunset") },
|
||||
{ value: "light", label: t("common.light") },
|
||||
{ value: "dark", label: t("common.dark") },
|
||||
]}
|
||||
|
||||
@@ -550,6 +550,7 @@ export function SettingsModal({
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: t("common.themeAutoFull") },
|
||||
{ value: "sunset", label: t("common.themeSunsetFull") },
|
||||
{ value: "light", label: t("common.light") },
|
||||
{ value: "dark", label: t("common.dark") },
|
||||
]}
|
||||
|
||||
@@ -35,6 +35,8 @@ const en = {
|
||||
"common.theme": "Theme",
|
||||
"common.themeAuto": "Auto",
|
||||
"common.themeAutoFull": "Auto (follows system)",
|
||||
"common.themeSunset": "Sunset",
|
||||
"common.themeSunsetFull": "Sunset (follows local time)",
|
||||
"common.light": "Light",
|
||||
"common.dark": "Dark",
|
||||
"common.saveFailed": "Save failed",
|
||||
@@ -585,6 +587,8 @@ const es: Dict = {
|
||||
"common.theme": "Tema",
|
||||
"common.themeAuto": "Automático",
|
||||
"common.themeAutoFull": "Automático (según el sistema)",
|
||||
"common.themeSunset": "Puesta de sol",
|
||||
"common.themeSunsetFull": "Puesta de sol (hora local)",
|
||||
"common.light": "Claro",
|
||||
"common.dark": "Oscuro",
|
||||
"common.saveFailed": "No se pudo guardar",
|
||||
@@ -1125,6 +1129,8 @@ const fr: Dict = {
|
||||
"common.theme": "Thème",
|
||||
"common.themeAuto": "Auto",
|
||||
"common.themeAutoFull": "Auto (suit le système)",
|
||||
"common.themeSunset": "Coucher de soleil",
|
||||
"common.themeSunsetFull": "Coucher de soleil (heure locale)",
|
||||
"common.light": "Clair",
|
||||
"common.dark": "Sombre",
|
||||
"common.saveFailed": "Échec de l'enregistrement",
|
||||
@@ -1665,6 +1671,8 @@ const ru: Dict = {
|
||||
"common.theme": "Тема",
|
||||
"common.themeAuto": "Авто",
|
||||
"common.themeAutoFull": "Авто (как в системе)",
|
||||
"common.themeSunset": "Закат",
|
||||
"common.themeSunsetFull": "Закат (местное время)",
|
||||
"common.light": "Светлая",
|
||||
"common.dark": "Тёмная",
|
||||
"common.saveFailed": "Не удалось сохранить",
|
||||
@@ -2192,6 +2200,8 @@ const it: Dict = {
|
||||
"common.theme": "Tema",
|
||||
"common.themeAuto": "Auto",
|
||||
"common.themeAutoFull": "Auto (segue il sistema)",
|
||||
"common.themeSunset": "Tramonto",
|
||||
"common.themeSunsetFull": "Tramonto (ora locale)",
|
||||
"common.light": "Chiaro",
|
||||
"common.dark": "Scuro",
|
||||
"common.saveFailed": "Salvataggio non riuscito",
|
||||
@@ -2725,6 +2735,8 @@ const pt: Dict = {
|
||||
"common.theme": "Tema",
|
||||
"common.themeAuto": "Auto",
|
||||
"common.themeAutoFull": "Auto (segue o sistema)",
|
||||
"common.themeSunset": "Pôr do sol",
|
||||
"common.themeSunsetFull": "Pôr do sol (hora local)",
|
||||
"common.light": "Claro",
|
||||
"common.dark": "Escuro",
|
||||
"common.saveFailed": "Falha ao guardar",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* "Sunset" theme: follows the browser's local time instead of the OS setting.
|
||||
* Day (07:00–19:00 local) -> light, night -> dark. No geolocation needed.
|
||||
*/
|
||||
|
||||
export const SOLAR_DAY_START_HOUR = 7;
|
||||
export const SOLAR_NIGHT_START_HOUR = 19;
|
||||
|
||||
/** True when the given local time falls in the night range (dark theme). */
|
||||
export function isSolarDark(now: Date = new Date()): boolean {
|
||||
const h = now.getHours();
|
||||
return h < SOLAR_DAY_START_HOUR || h >= SOLAR_NIGHT_START_HOUR;
|
||||
}
|
||||
|
||||
/** Milliseconds from `now` until the next 07:00 / 19:00 local boundary. */
|
||||
export function msUntilNextSolarSwitch(now: Date = new Date()): number {
|
||||
const next = new Date(now);
|
||||
const h = now.getHours();
|
||||
if (h < SOLAR_DAY_START_HOUR) {
|
||||
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||
} else if (h < SOLAR_NIGHT_START_HOUR) {
|
||||
next.setHours(SOLAR_NIGHT_START_HOUR, 0, 0, 0);
|
||||
} else {
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||
}
|
||||
return Math.max(1000, next.getTime() - now.getTime());
|
||||
}
|
||||
|
||||
/** Resolve a stored theme value to dark/light. `sunset` uses local time. */
|
||||
export function resolveThemeDark(theme: string, now: Date = new Date()): boolean {
|
||||
if (theme === "dark") return true;
|
||||
if (theme === "light") return false;
|
||||
if (theme === "sunset") return isSolarDark(now);
|
||||
if (typeof window !== "undefined" && window.matchMedia) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+4
-4
@@ -183,7 +183,7 @@ function cleanOptionalStr(v: unknown, max: number): string | null {
|
||||
}
|
||||
|
||||
function validTheme(v: unknown): v is string {
|
||||
return v === "auto" || v === "light" || v === "dark";
|
||||
return v === "auto" || v === "light" || v === "dark" || v === "sunset";
|
||||
}
|
||||
|
||||
function validAvatar(v: unknown): string | null {
|
||||
@@ -633,7 +633,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
patch.avatarUrl = a;
|
||||
}
|
||||
if (body.theme !== undefined) {
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset" }, 400);
|
||||
patch.theme = body.theme;
|
||||
}
|
||||
if (body.email !== undefined) {
|
||||
@@ -763,7 +763,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
|
||||
if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
|
||||
const theme = body.theme === undefined ? "auto" : body.theme;
|
||||
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400);
|
||||
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark|sunset" }, 400);
|
||||
const email = body.email === undefined ? "" : validEmail(body.email);
|
||||
if (email === null) return json({ error: "invalid email address" }, 400);
|
||||
const key = newAccessKey();
|
||||
@@ -809,7 +809,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
patch.avatarUrl = a;
|
||||
}
|
||||
if (body.theme !== undefined) {
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
|
||||
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset|sunset" }, 400);
|
||||
patch.theme = body.theme;
|
||||
}
|
||||
if (body.email !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user