This commit is contained in:
Nathan Anderson 2026-02-15 03:00:24 +00:00
parent d487282fa5
commit dc6053fc71
8 changed files with 2597 additions and 73 deletions

View File

@ -121,9 +121,9 @@ Checks and fix:
`_fresh/server.js` and `_fresh/static/` are found (e.g. in systemd set
`WorkingDirectory=` to the app directory).
3. For reproducible builds across machines, consider re-enabling the lockfile
in `deno.json` (`"lock": true`) and committing `deno.lock`, then using the
same lock on the server.
3. For reproducible builds across machines, consider re-enabling the lockfile in
`deno.json` (`"lock": true`) and committing `deno.lock`, then using the same
lock on the server.
4. Do not use `esm.sh` for Preact (this project already uses `npm:preact`);
duplicate Preact from mixed sources is a common cause.

View File

@ -1,9 +1,8 @@
{
"lock": false,
"tasks": {
"check": "deno fmt && deno fmt --check . && deno lint . && deno check",
"dev": "vite",
"build": "vite build",
"build": "deno install -r --allow-scripts && vite build",
"start": "deno serve -A --port 8080 _fresh/server.js",
"update": "deno run -A -r jsr:@fresh/update .",
"e2e": "deno run -A npm:playwright test",
@ -75,12 +74,8 @@
],
"types": ["vite/client"]
},
"test": {
"exclude": ["node_modules/", "_fresh/"]
},
"fmt": {
"exclude": ["node_modules/", "_fresh/"]
},
"test": { "exclude": ["node_modules/", "_fresh/"] },
"fmt": { "exclude": ["node_modules/", "_fresh/"] },
"nodeModulesDir": "auto",
"unstable": ["kv"]
}

2502
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@ interface MemberHomeProps {
}
export default function MemberHome(
{ onboardingStatus, serverCalendarYear, serverCalendarMonth }: MemberHomeProps,
{ onboardingStatus, serverCalendarYear, serverCalendarMonth }:
MemberHomeProps,
) {
useEffect(() => {
const lockRoute = localStorage.getItem("lock-route");

View File

@ -0,0 +1,71 @@
import { useEffect } from "preact/hooks";
const THEME_KEY = "cyberhybrid-theme-preference";
function applyTheme(root: Element, mode: string): void {
root.setAttribute("data-theme-mode", mode);
}
function readTheme(): string {
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark") return stored;
} catch {
// ignore
}
return "dark";
}
function syncToggleLabels(root: Element): void {
const isDark = root.getAttribute("data-theme-mode") !== "light";
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
const toggles = root.querySelectorAll("[data-theme-toggle]");
toggles.forEach((el) => {
el.setAttribute("aria-label", label);
el.setAttribute("title", label);
});
}
function persistTheme(mode: string): void {
try {
localStorage.setItem(THEME_KEY, mode);
} catch {
// ignore
}
}
/**
* Client-only island that applies saved theme and wires up theme toggle clicks.
* Renders nothing; runs theme logic in useEffect to avoid dangerouslySetInnerHTML.
*/
export default function ThemeHydration() {
useEffect(() => {
const root = document.documentElement;
applyTheme(root, readTheme());
syncToggleLabels(root);
const handleClick = (event: Event) => {
const target = event.target as Node;
if (!target || !(target instanceof Element)) return;
const toggle = target.closest("[data-theme-toggle]");
if (!toggle) return;
event.preventDefault();
event.stopPropagation();
const current = root.getAttribute("data-theme-mode") === "light"
? "light"
: "dark";
const next = current === "dark" ? "light" : "dark";
applyTheme(root, next);
persistTheme(next);
syncToggleLabels(root);
};
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
}, []);
return null;
}

View File

@ -13,7 +13,8 @@ import {
* Native-rendered dropdown content for <details> menu.
*/
export default function NavigationMenu(
{ isLoggedIn, currentRoute, navigationGroups, serverCalendarPath }: NavigationMenuProps,
{ isLoggedIn, currentRoute, navigationGroups, serverCalendarPath }:
NavigationMenuProps,
) {
function getItemStateClass(item: NavigationItem): string {
if (item.disabled) return "navigation-menu-item--disabled";

View File

@ -16,7 +16,14 @@ function normalizeRoute(path: string): string {
* Main navigation bar with user info, encryption status, and menu.
*/
export default function WelcomeBar(
{ publicId, user_name, currentRoute, isEncrypted, stateId, serverCalendarPath }: WelcomeBarProps,
{
publicId,
user_name,
currentRoute,
isEncrypted,
stateId,
serverCalendarPath,
}: WelcomeBarProps,
) {
const [activeRoute, setActiveRoute] = useState(normalizeRoute(currentRoute));
const [state] = useState<WelcomeBarState>({

View File

@ -1,6 +1,7 @@
// _app.tsx
import { define, type PageProps } from "../utils.ts";
import WelcomeBar from "../islands/WelcomeBar/WelcomeBar.tsx";
import ThemeHydration from "../islands/ThemeHydration.tsx";
import { createPublicId } from "../helpers.ts";
import { SessionCookie } from "./auth/callback.ts";
@ -24,10 +25,12 @@ export default define.page(async function App(props: PageProps) {
: "";
const now = new Date();
const serverCalendarPath =
`/calendar/financial-calendar/${now.getFullYear()}/${(now.getMonth() + 1).toString().padStart(2, "0")}`;
`/calendar/financial-calendar/${now.getFullYear()}/${
(now.getMonth() + 1).toString().padStart(2, "0")
}`;
return (
<html>
<html data-theme-mode="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -45,63 +48,7 @@ export default define.page(async function App(props: PageProps) {
<div class="page-offset">
<Component />
</div>
<script
dangerouslySetInnerHTML={{
__html: `(function () {
var KEY = "cyberhybrid-theme-preference";
var root = document.documentElement;
function applyTheme(mode) {
root.setAttribute("data-theme-mode", mode);
}
function readTheme() {
try {
var stored = localStorage.getItem(KEY);
if (stored === "light" || stored === "dark") return stored;
} catch (_error) {}
return "dark";
}
function syncToggleLabels() {
var isDark = root.getAttribute("data-theme-mode") !== "light";
var label = isDark ? "Switch to light mode" : "Switch to dark mode";
var toggles = document.querySelectorAll("[data-theme-toggle]");
for (var i = 0; i < toggles.length; i++) {
toggles[i].setAttribute("aria-label", label);
toggles[i].setAttribute("title", label);
}
}
function persistTheme(mode) {
try {
localStorage.setItem(KEY, mode);
} catch (_error) {}
}
applyTheme(readTheme());
syncToggleLabels();
document.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof Element)) return;
var toggle = target.closest("[data-theme-toggle]");
if (!toggle) return;
event.preventDefault();
event.stopPropagation();
var current = root.getAttribute("data-theme-mode") === "light"
? "light"
: "dark";
var next = current === "dark" ? "light" : "dark";
applyTheme(next);
persistTheme(next);
syncToggleLabels();
}, true);
})();`,
}}
/>
<ThemeHydration />
</body>
</html>
);