diff --git a/app/api/operator/communications/route.ts b/app/api/operator/communications/route.ts index bf35ffb5..f8cfbde2 100644 --- a/app/api/operator/communications/route.ts +++ b/app/api/operator/communications/route.ts @@ -23,7 +23,29 @@ function validDate(value: unknown, optional = false): string | null | undefined } function slugify(value: string) { - return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100); + return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100).replace(/-+$/g, ""); +} + +export async function GET(request: Request) { + const user = await getServerUser(); + if (!user) return json({ error: "Sign in to continue." }, 401); + if (!isTidelightOperator(user)) return json({ error: "Operator access is required." }, 403); + const params = new URL(request.url).searchParams; + const kind = params.get("kind"); + const offset = Number(params.get("offset") ?? "0"); + if ((kind !== "maintenance" && kind !== "blog") || !Number.isInteger(offset) || offset < 0 || offset > 1000000) { + return json({ error: "Choose a valid content list and page offset." }, 400); + } + const client = createAdminClient(); + const table = kind === "maintenance" ? "tic_status_maintenance" : "tic_blog_posts"; + const columns = kind === "maintenance" + ? "id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at" + : "id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at"; + const orderColumn = kind === "maintenance" ? "starts_at" : "updated_at"; + const { data, count, error } = await client.from(table).select(columns, { count: "exact" }) + .order(orderColumn, { ascending: false }).order("id", { ascending: true }).range(offset, offset + 49); + if (error) return json({ error: "The content list could not be loaded." }, 500); + return json({ items: data ?? [], hasMore: offset + (data?.length ?? 0) < (count ?? 0) }); } export async function POST(request: Request) { diff --git a/app/i18n/zh-CN.json b/app/i18n/zh-CN.json index 3ffd6750..0c6ebdb2 100644 --- a/app/i18n/zh-CN.json +++ b/app/i18n/zh-CN.json @@ -6184,5 +6184,5 @@ "SOURCE-LINKED": "来源可追溯", "PAPER-FIRST CONTROLS": "模拟交易优先控制", "Illustrative workflow · no live activity shown": "示意工作流 · 未显示实时活动", - "Choose Dark or White. Dark is the default theme.": "请选择深色或白色主题。深色为默认主题。" + "Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.": "选择浅色或深色主题、跟随设备设置,或让 Tidelight 根据您所在时区的本地时间自动切换。" } diff --git a/app/layout.tsx b/app/layout.tsx index ee97efa8..3b80f51b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,6 +23,16 @@ import "./account-center.css"; import "./notifications/notifications.css"; import { getServerUser } from "@/lib/supabase/auth-user"; +const validTheme = (value: string | undefined): value is "dark" | "white" => value === "dark" || value === "white"; +function automaticTheme(timeZone: string) { + try { + const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone }).format(new Date())); + return hour >= 7 && hour < 19 ? "white" : "dark"; + } catch { + return "dark"; + } +} + const baseMetadata: Metadata = { metadataBase: new URL("https://tidelight.app"), alternates: { canonical: "https://tidelight.app" }, @@ -66,8 +76,12 @@ export async function generateMetadata(): Promise { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { const cookieStore = await cookies(); const savedTheme = cookieStore.get("tidelight-theme")?.value; - const themeMode = cookieStore.get("tidelight-theme-mode")?.value; - const theme = themeMode === "manual" && (savedTheme === "white" || savedTheme === "dark") ? savedTheme : "dark"; + const savedThemeMode = cookieStore.get("tidelight-theme-mode")?.value; + const themeMode = savedThemeMode === "manual" || savedThemeMode === "device" ? savedThemeMode : "auto"; + const timeZone = cookieStore.get("tidelight-timezone")?.value ?? "UTC"; + const theme = themeMode === "manual" && validTheme(savedTheme) + ? savedTheme + : themeMode === "auto" ? automaticTheme(timeZone) : validTheme(savedTheme) ? savedTheme : automaticTheme(timeZone); const requestHeaders = await headers(); const requestHost = (requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "").split(",")[0].trim().toLowerCase().replace(/:\d+$/, ""); const isPublicPackageHost = requestHost === "package.tidelight.app"; @@ -90,6 +104,6 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac || user?.email?.split("@")[0] || null; - return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; + return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; } diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index c9151188..53d8190e 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type FormEvent, type ReactNode } from "react"; +import { useRef, useState, type FormEvent, type ReactNode } from "react"; import type { EditorialPost, MaintenanceNotice } from "@/lib/tic/editorial-content"; function localDateTime(value: string | null | undefined) { @@ -9,19 +9,95 @@ function localDateTime(value: string | null | undefined) { return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); } +function toIsoTimestamp(value: FormDataEntryValue | undefined) { + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + function FormField({ label, children }: { label: string; children: ReactNode }) { return ; } export function CommunicationsEditor({ initialMaintenance, initialPosts }: { initialMaintenance: MaintenanceNotice[]; initialPosts: EditorialPost[] }) { const [maintenance, setMaintenance] = useState(initialMaintenance); + const [maintenanceOffset, setMaintenanceOffset] = useState(initialMaintenance.length); const [posts, setPosts] = useState(initialPosts); + const [postsOffset, setPostsOffset] = useState(initialPosts.length); + const [maintenanceHasMore, setMaintenanceHasMore] = useState(initialMaintenance.length === 50); + const [postsHaveMore, setPostsHaveMore] = useState(initialPosts.length === 50); + const [maintenanceVisible, setMaintenanceVisible] = useState(12); + const [postsVisible, setPostsVisible] = useState(12); + const [loadingMore, setLoadingMore] = useState<"maintenance" | "blog" | null>(null); + const paginationGeneration = useRef({ maintenance: 0, blog: 0 }); const [editingMaintenance, setEditingMaintenance] = useState(null); const [editingPost, setEditingPost] = useState(null); const [maintenanceMessage, setMaintenanceMessage] = useState(""); const [postMessage, setPostMessage] = useState(""); const [busy, setBusy] = useState(false); + async function showMore(kind: "maintenance" | "blog") { + const items = kind === "maintenance" ? maintenance : posts; + const offset = kind === "maintenance" ? maintenanceOffset : postsOffset; + const visible = kind === "maintenance" ? maintenanceVisible : postsVisible; + if (visible < items.length) { + kind === "maintenance" ? setMaintenanceVisible(visible + 12) : setPostsVisible(visible + 12); + return; + } + const hasMore = kind === "maintenance" ? maintenanceHasMore : postsHaveMore; + if (!hasMore) return; + const generation = paginationGeneration.current[kind]; + setLoadingMore(kind); + try { + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${offset}`); + const result = await response.json() as { items?: Array; hasMore?: boolean; error?: string }; + if (!response.ok || !result.items) throw new Error(result.error ?? "Older entries could not be loaded."); + if (generation !== paginationGeneration.current[kind]) return; + if (kind === "maintenance") { + setMaintenance(current => [...current, ...(result.items as MaintenanceNotice[])]); + setMaintenanceOffset(offset + result.items.length); + setMaintenanceHasMore(Boolean(result.hasMore)); + setMaintenanceVisible(visible + result.items.length); + } else { + setPosts(current => [...current, ...(result.items as EditorialPost[])]); + setPostsOffset(offset + result.items.length); + setPostsHaveMore(Boolean(result.hasMore)); + setPostsVisible(visible + result.items.length); + } + } catch (error) { + if (generation === paginationGeneration.current[kind]) { + const message = error instanceof Error ? error.message : "Older entries could not be loaded."; + kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message); + } + } finally { + if (generation === paginationGeneration.current[kind]) setLoadingMore(null); + } + } + + async function refreshList(kind: "maintenance" | "blog") { + paginationGeneration.current[kind] += 1; + setLoadingMore(current => current === kind ? null : current); + try { + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`); + const result = await response.json() as { items?: Array; hasMore?: boolean }; + if (!response.ok || !result.items) return false; + if (kind === "maintenance") { + setMaintenance(result.items as MaintenanceNotice[]); + setMaintenanceOffset(result.items.length); + setMaintenanceHasMore(Boolean(result.hasMore)); + setMaintenanceVisible(12); + } else { + setPosts(result.items as EditorialPost[]); + setPostsOffset(result.items.length); + setPostsHaveMore(Boolean(result.hasMore)); + setPostsVisible(12); + } + return true; + } catch { + return false; + } + } + async function submit(event: FormEvent, kind: "maintenance" | "blog") { event.preventDefault(); const form = event.currentTarget; @@ -29,7 +105,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini const editing = kind === "maintenance" ? editingMaintenance : editingPost; const publishedAt = editingPost?.published_at; const payload = kind === "maintenance" - ? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: raw.startsAt, endsAt: raw.endsAt || null, status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) } + ? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: toIsoTimestamp(raw.startsAt), endsAt: toIsoTimestamp(raw.endsAt), status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) } : { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, category: raw.category, slug: raw.slug, body: raw.body, status: raw.status, publishedAt }; setBusy(true); kind === "maintenance" ? setMaintenanceMessage("") : setPostMessage(""); @@ -37,16 +113,27 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini const response = await fetch("/api/operator/communications", { method: editing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const result = await response.json() as { item?: MaintenanceNotice | EditorialPost; error?: string }; if (!response.ok || !result.item) throw new Error(result.error ?? "The update could not be saved."); + const refreshed = await refreshList(kind); if (kind === "maintenance") { - const item = result.item as MaintenanceNotice; - setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at))); + if (!refreshed) { + const item = result.item as MaintenanceNotice; + setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at))); + setMaintenanceHasMore(false); + } setEditingMaintenance(null); - setMaintenanceMessage(editing ? "Maintenance notice updated." : "Maintenance notice published."); + setMaintenanceMessage(refreshed + ? (editing ? "Maintenance notice updated." : "Maintenance notice published.") + : "Saved, but the list could not refresh. Reload this page before loading older notices."); } else { const item = result.item as EditorialPost; - setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))); + if (!refreshed) { + setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))); + setPostsHaveMore(false); + } setEditingPost(null); - setPostMessage(item.status === "published" ? "Field Notes article published." : "Draft saved."); + setPostMessage(refreshed + ? (item.status === "published" ? "Field Notes article published." : "Draft saved.") + : "Saved, but the list could not refresh. Reload this page before loading older articles."); } form.reset(); } catch (error) { @@ -77,7 +164,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini

{maintenanceMessage}

Recent notices

- {maintenance.length ?
    {maintenance.slice(0, 12).map(item =>
  • {item.title} · {item.status} · {new Date(item.starts_at).toLocaleString()}
  • )}
:

No notices yet.

} + {maintenance.length ?
    {maintenance.slice(0, maintenanceVisible).map(item =>
  • {item.title} · {item.status} · {new Date(item.starts_at).toLocaleString()}
  • )}
:

No notices yet.

}{maintenance.length > maintenanceVisible || maintenanceHasMore ? : null}
@@ -98,7 +185,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini

{postMessage}

Recent articles

- {posts.length ?
    {posts.slice(0, 12).map(item =>
  • {item.title} · {item.status}
  • )}
:

No articles yet.

} + {posts.length ?
    {posts.slice(0, postsVisible).map(item =>
  • {item.title} · {item.status}
  • )}
:

No articles yet.

}{posts.length > postsVisible || postsHaveMore ? : null}
; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f2f98127..f9a034d9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -56,7 +56,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro {params.setup ?

Your account is ready. Choose a 4–8 character username to finish setting up your Tidelight profile.

: null}{params.updated ?

Your preference has been saved.

: null}{params.error ?

We couldn’t save that change. Try again.

: null}
文
LANGUAGE / 语言

Choose your language.

Your language choice is saved on this device and used throughout Tidelight.

-
◐
APPEARANCE

Your desk, your light.

Choose Dark or White. Dark is the default theme.

+
◐
APPEARANCE

Your desk, your light.

Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.

◉
ACCOUNT

Your workspace

{user ? <>
{communityProfile?.avatar_url ? : (communityProfile?.display_name ?? user.email ?? "T").slice(0, 1).toUpperCase()}
{communityProfile?.display_name ?? user.email}@{communityProfile?.handle ?? "set up a community handle"} · sign-in protected
PRIVATE
Research lensThis preference is saved to your account.
:

You’re browsing as a guest. Sign in to save preferences and keep your research private across sessions.

Sign in ↗
}
diff --git a/app/status/maintenance/page.tsx b/app/status/maintenance/page.tsx index 4887ffb1..28cf432f 100644 --- a/app/status/maintenance/page.tsx +++ b/app/status/maintenance/page.tsx @@ -10,6 +10,13 @@ import { StatusActions } from "../status-actions"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Maintenance · Tidelight status", description: "Scheduled maintenance notices for Tidelight services." }; +function formatStatusTime(value: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "numeric", minute: "2-digit", timeZone: "UTC", timeZoneName: "short", + }).format(new Date(value)); +} + function noticeLabel(notice: MaintenanceNotice) { if (notice.status === "completed") return "Completed"; if (notice.status === "ongoing") return "In progress"; @@ -40,10 +47,10 @@ export default async function MaintenancePage() { {unavailable ?
Maintenance updates are temporarily unavailable

Please check the live service status while we restore the notice feed.

: currentNotices.length ?
{currentNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

{noticeLabel(notice)}

{notice.subtitle}

{notice.message}

-
Started {notice.ends_at ? Expected completion : null}
+
Started {notice.ends_at ? Expected completion : null}
{notice.affected_services.length ?
Affected services
    {notice.affected_services.map(service =>
  • {service}
  • )}
: null}
)}
:
No scheduled maintenance

There are no active or upcoming maintenance notices at this time.

} - {pastNotices.length ?

Past maintenance

{pastNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

Completed

{notice.subtitle}

{notice.message}

Started {notice.ends_at ? Completed : null}
)}
: null} + {pastNotices.length ?

Past maintenance

{pastNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

Completed

{notice.subtitle}

{notice.message}

Started {notice.ends_at ? Completed : null}
)}
: null} ← Back to current status diff --git a/app/theme-polish.css b/app/theme-polish.css index 4e9f7160..8d8f186d 100644 --- a/app/theme-polish.css +++ b/app/theme-polish.css @@ -458,3 +458,38 @@ .app-shell .station-panel-head { align-items:flex-start; } .app-shell .station-history .station-panel-head > .station-subtle { max-width:100%; white-space:normal; } } + + +/* Match the reference's recognizable sun, device, and moon controls. */ +.theme-quick-option .theme-quick-icon { + width: 14px; + height: 14px; + color: var(--muted); + transition: color .16s ease, transform .16s ease; +} +.theme-quick-option:hover .theme-quick-icon, +.theme-quick-option.selected .theme-quick-icon { + color: var(--brand-aqua); +} +.theme-quick-option:hover .theme-quick-icon { transform: scale(1.08); } +:root[data-theme="white"] .theme-quick-option.selected .theme-quick-icon { color: #286a4d; } +.theme-option-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: 0 0 34px; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--brand-teal); + background: color-mix(in srgb, var(--brand-teal) 8%, var(--panel)); +} +.theme-option-icon svg { width: 19px; height: 19px; } +.theme-option.selected .theme-option-icon { + border-color: color-mix(in srgb, var(--brand-teal) 55%, var(--line)); + background: color-mix(in srgb, var(--brand-teal) 15%, var(--panel)); +} +.theme-option-copy small { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; } +@media (max-width: 780px) { + .mobile-utility-controls .theme-quick-icon { width: 12px; height: 12px; } +} diff --git a/app/ui/theme-provider.tsx b/app/ui/theme-provider.tsx index 621c4481..a60b8b03 100644 --- a/app/ui/theme-provider.tsx +++ b/app/ui/theme-provider.tsx @@ -3,63 +3,138 @@ import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from "react"; export type Theme = "dark" | "white"; +export type ThemeMode = "auto" | "device" | "manual"; export type ExperienceMode = "mini" | "pro"; + export const themes: { id: Theme; name: string; detail: string; swatches: string[] }[] = [ { id: "dark", name: "Dark", detail: "Graphite, low-glare surfaces", swatches: ["#090a0c", "#d8e1dc", "#202328"] }, { id: "white", name: "White", detail: "Bright white, crisp contrast", swatches: ["#ffffff", "#153b31", "#e8f0eb"] }, ]; + const DEFAULT_THEME: Theme = "dark"; -const ThemeContext = createContext<{ theme: Theme; setTheme: (theme: Theme) => void }>({ theme: DEFAULT_THEME, setTheme: () => {} }); +const ThemeContext = createContext<{ theme: Theme; mode: ThemeMode; setTheme: (theme: Theme) => void; setMode: (mode: ThemeMode) => void }>({ + theme: DEFAULT_THEME, mode: "auto", setTheme: () => {}, setMode: () => {}, +}); const ExperienceContext = createContext<{ mode: ExperienceMode; setMode: (mode: ExperienceMode) => void }>({ mode: "pro", setMode: () => {} }); + const validTheme = (value: string | null): value is Theme => value === "white" || value === "dark"; +const cookieValue = (name: string) => document.cookie.split(";").map(part => part.trim()).find(part => part.startsWith(`${name}=`))?.slice(name.length + 1) ?? null; +const sharedDomain = () => location.hostname === "tidelight.app" || location.hostname.endsWith(".tidelight.app") + ? `; Domain=.tidelight.app${location.protocol === "https:" ? "; Secure" : ""}` + : ""; +const writeCookie = (name: string, value: string) => { + document.cookie = `${name}=${value}; Path=/; SameSite=Lax; Max-Age=31536000${sharedDomain()}`; +}; +const getTimeZone = () => { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } + catch { return "UTC"; } +}; +const automaticTheme = (timeZone: string): Theme => { + try { + const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone }).format(new Date())); + return hour >= 7 && hour < 19 ? "white" : "dark"; + } catch { + return automaticTheme("UTC"); + } +}; +const systemTheme = (): Theme => window.matchMedia?.("(prefers-color-scheme: light)").matches ? "white" : "dark"; +const getThemeMode = (): ThemeMode => { + const value = cookieValue("tidelight-theme-mode"); + return value === "manual" || value === "device" ? value : "auto"; +}; const getTheme = (): Theme => { - const shared = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("tidelight-theme="))?.slice("tidelight-theme=".length) ?? null; - const mode = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("tidelight-theme-mode="))?.slice("tidelight-theme-mode=".length); - return mode === "manual" && validTheme(shared) ? shared : DEFAULT_THEME; + const mode = getThemeMode(); + if (mode === "device") return systemTheme(); + if (mode === "manual") { + const selected = cookieValue("tidelight-theme"); + if (validTheme(selected)) return selected; + } + return automaticTheme(getTimeZone()); }; -const saveTheme = (theme: Theme) => { - try { window.localStorage.removeItem("tidelight-theme"); } catch { /* Theme selection still works when storage is blocked. */ } - const sharedDomain = location.hostname === "tidelight.app" || location.hostname.endsWith(".tidelight.app") ? `; Domain=.tidelight.app${location.protocol === "https:" ? "; Secure" : ""}` : ""; - // Keep an explicit choice available across Tidelight pages and subdomains in this browser session. - document.cookie = `tidelight-theme=${theme}; Path=/; SameSite=Lax${sharedDomain}`; - document.cookie = `tidelight-theme-mode=manual; Path=/; SameSite=Lax${sharedDomain}`; +const syncTheme = (theme: Theme) => { + writeCookie("tidelight-timezone", getTimeZone()); + writeCookie("tidelight-theme", theme); }; -const getExperienceMode = (): ExperienceMode => window.localStorage.getItem("tidelight-experience") === "mini" ? "mini" : "pro"; const subscribe = (callback: () => void) => { window.addEventListener("storage", callback); window.addEventListener("tidelight-theme-change", callback); - return () => { window.removeEventListener("storage", callback); window.removeEventListener("tidelight-theme-change", callback); }; + const media = window.matchMedia?.("(prefers-color-scheme: light)"); + media?.addEventListener?.("change", callback); + const interval = window.setInterval(callback, 60_000); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener("tidelight-theme-change", callback); + media?.removeEventListener?.("change", callback); + window.clearInterval(interval); + }; }; -export function ThemeProvider({ children, initialTheme = DEFAULT_THEME }: { children: ReactNode; initialTheme?: Theme }) { +export function ThemeProvider({ children, initialTheme = DEFAULT_THEME, initialMode = "auto" }: { children: ReactNode; initialTheme?: Theme; initialMode?: ThemeMode }) { const theme = useSyncExternalStore(subscribe, getTheme, (): Theme => initialTheme); - const mode = useSyncExternalStore(subscribe, getExperienceMode, (): ExperienceMode => "pro"); - const setMode = (next: ExperienceMode) => { - window.localStorage.setItem("tidelight-experience", next); - window.dispatchEvent(new Event("tidelight-theme-change")); - }; + const mode = useSyncExternalStore(subscribe, getThemeMode, (): ThemeMode => initialMode); + const experienceMode = useSyncExternalStore( + callback => { + window.addEventListener("storage", callback); + window.addEventListener("tidelight-theme-change", callback); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener("tidelight-theme-change", callback); + }; + }, + (): ExperienceMode => window.localStorage.getItem("tidelight-experience") === "mini" ? "mini" : "pro", + (): ExperienceMode => "pro", + ); + useEffect(() => { - saveTheme(theme); + syncTheme(theme); document.documentElement.dataset.theme = theme; - document.documentElement.dataset.themeMode = "manual"; - document.documentElement.dataset.experience = mode; + document.documentElement.dataset.themeMode = mode; + document.documentElement.dataset.experience = experienceMode; document.documentElement.style.colorScheme = theme === "white" ? "light" : "dark"; - }, [mode, theme]); + }, [experienceMode, mode, theme]); + const setTheme = (next: Theme) => { - saveTheme(next); + writeCookie("tidelight-theme-mode", "manual"); + writeCookie("tidelight-theme", next); + window.dispatchEvent(new Event("tidelight-theme-change")); + }; + const setMode = (next: ThemeMode) => { + writeCookie("tidelight-theme-mode", next); + if (next === "auto") writeCookie("tidelight-timezone", getTimeZone()); + window.dispatchEvent(new Event("tidelight-theme-change")); + }; + const setExperienceMode = (next: ExperienceMode) => { + window.localStorage.setItem("tidelight-experience", next); window.dispatchEvent(new Event("tidelight-theme-change")); }; - return {children}; + + return + {children} + ; +} + +function ThemeIcon({ name, className }: { name: "sun" | "device" | "moon" | "auto"; className?: string }) { + const shared = { className, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true as const }; + if (name === "sun") return ; + if (name === "device") return ; + if (name === "moon") return ; + return ; } export function ThemePicker() { - const { theme, setTheme } = useContext(ThemeContext); + const { theme, mode, setTheme, setMode } = useContext(ThemeContext); + const options = [ + { id: "auto", label: "Automatic", detail: "Follows local time", icon: "auto" as const, selected: mode === "auto", choose: () => setMode("auto") }, + { id: "device", label: "Device", detail: "Follows device setting", icon: "device" as const, selected: mode === "device", choose: () => setMode("device") }, + { id: "white", label: "Light", detail: "Bright, crisp contrast", icon: "sun" as const, selected: mode === "manual" && theme === "white", choose: () => setTheme("white") }, + { id: "dark", label: "Dark", detail: "Low-glare surfaces", icon: "moon" as const, selected: mode === "manual" && theme === "dark", choose: () => setTheme("dark") }, + ]; return
Choose a color theme - {themes.map((option) => )}
; } @@ -69,10 +144,15 @@ export function useExperience() { } export function ThemeQuickSwitch() { - const { theme, setTheme } = useContext(ThemeContext); - return
- {themes.map((option) => )}
; } diff --git a/lib/tic/editorial-content.ts b/lib/tic/editorial-content.ts index db909ea7..54f32c0a 100644 --- a/lib/tic/editorial-content.ts +++ b/lib/tic/editorial-content.ts @@ -33,7 +33,7 @@ const admin = () => createAdminClient(); export async function getMaintenanceNotices(): Promise { const { data, error } = await admin().from("tic_status_maintenance") .select("id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at") - .order("starts_at", { ascending: false }).limit(50); + .order("starts_at", { ascending: false }).order("id", { ascending: true }).limit(50); if (error) throw new Error("Maintenance notices could not be loaded."); return (data ?? []) as MaintenanceNotice[]; } @@ -43,10 +43,10 @@ export async function getOperatorEditorialContent(): Promise<{ maintenance: Main const [maintenance, posts] = await Promise.all([ client.from("tic_status_maintenance") .select("id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at") - .order("starts_at", { ascending: false }).limit(50), + .order("starts_at", { ascending: false }).order("id", { ascending: true }).limit(50), client.from("tic_blog_posts") .select("id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at") - .order("updated_at", { ascending: false }).limit(50), + .order("updated_at", { ascending: false }).order("id", { ascending: true }).limit(50), ]); if (maintenance.error || posts.error) throw new Error("Operator content could not be loaded."); return { diff --git a/tests/theme-auto.test.mjs b/tests/theme-auto.test.mjs index 08d4a4f8..c82c9c6d 100644 --- a/tests/theme-auto.test.mjs +++ b/tests/theme-auto.test.mjs @@ -2,10 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -test("new visits use Dark rather than resolving a palette from the local clock", async () => { +test("new visits follow local time and saved preferences can use device or manual themes", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); - assert.match(provider, /const DEFAULT_THEME: Theme = "dark"/); - assert.doesNotMatch(provider, /automaticTheme|"auto"|daylight|Night Tide|Blue Hour/); - assert.match(layout, /: "dark";/); + assert.ok(provider.includes('export type ThemeMode = "auto" | "device" | "manual"')); + assert.ok(provider.includes("automaticTheme(getTimeZone())")); + assert.ok(provider.includes('window.matchMedia?.("(prefers-color-scheme: light)")')); + assert.ok(layout.includes("automaticTheme(timeZone)")); + assert.ok(layout.includes('themeMode === "manual" && validTheme(savedTheme)')); }); diff --git a/tests/theme-palette.test.mjs b/tests/theme-palette.test.mjs index e7f0ce9b..8709222c 100644 --- a/tests/theme-palette.test.mjs +++ b/tests/theme-palette.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -test("Dark and White are the only themes, with Dark as the default", async () => { +test("Light and Dark palettes support automatic and device preferences", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); const css = await readFile(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -10,9 +10,13 @@ test("Dark and White are the only themes, with Dark as the default", async () => assert.match(provider, /const DEFAULT_THEME: Theme = "dark"/); assert.match(provider, /id: "dark", name: "Dark"/); assert.match(provider, /id: "white", name: "White"/); - assert.doesNotMatch(provider, /id: "(?:daylight|night|blue)"|automaticTheme|"auto"/); + assert.match(provider, /id: "auto", label: "Automatic"/); + assert.match(provider, /id: "device", label: "Device"/); + assert.ok(provider.includes('icon: "sun"')); + assert.ok(provider.includes('icon: "device"')); + assert.ok(provider.includes('icon: "moon"')); assert.match(provider, /value === "white" \|\| value === "dark"/); - assert.match(layout, /savedTheme === "white" \|\| savedTheme === "dark"\) \? savedTheme : "dark"/); + assert.ok(layout.includes("themeMode === \"manual\" && validTheme(savedTheme)")); assert.match(css, /\.theme-picker\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); assert.match(css, /:root\[data-theme="white"\]\{color-scheme:light/); assert.match(css, /:root\[data-theme="dark"\]\{color-scheme:dark/);