themes lol

This commit is contained in:
2026-08-23 17:17:04 -07:00
parent 0a7b5fa00f
commit 6f575d6a23
79 changed files with 13108 additions and 278 deletions
@@ -0,0 +1,111 @@
import {
Box,
SimpleGrid,
Stack,
Text,
UnstyledButton,
useComputedColorScheme,
} from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { updateUserTheme } from "@/features/settings/server";
import { useAuth } from "@/contexts/auth-context";
import { DEFAULT_THEME_ID, THEME_LIST, getTheme } from "@/themes/registry";
import ThemePreview from "./theme-preview";
const ThemePicker = () => {
const { metadata, user, set } = useAuth();
const { t } = useLingui();
const sheet = useSheet();
const scheme = useComputedColorScheme("dark");
const currentId = metadata.theme || DEFAULT_THEME_ID;
const current = getTheme(currentId);
const handleSelect = async (id: string) => {
if (!user || id === currentId) return;
const previous = metadata;
set({ metadata: { ...metadata, theme: id } });
try {
await updateUserTheme({ data: id });
} catch {
set({ metadata: previous });
}
};
return (
<Box>
<Text fw={500} size="sm" mb="xs">
<Trans>Theme</Trans>
</Text>
<UnstyledButton
onClick={sheet.open}
w="100%"
style={{
display: "flex",
alignItems: "center",
gap: "var(--mantine-spacing-sm)",
padding: "var(--mantine-spacing-xs)",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Box w="3.25rem" style={{ flexShrink: 0 }}>
<ThemePreview
manifest={current}
scheme={scheme}
height="2.25rem"
scale={0.5}
/>
</Box>
<Text size="sm" fw={500} style={{ flex: 1, textAlign: "left" }}>
{t(current.label)}
</Text>
<CaretRightIcon size={16} color="var(--mantine-color-dimmed)" />
</UnstyledButton>
<Sheet title={t`Theme`} {...sheet.props}>
<SimpleGrid cols={3} spacing="sm" py="sm">
{[...THEME_LIST]
.sort(
(a, b) =>
(a.order ?? 100) - (b.order ?? 100) ||
t(a.label).localeCompare(t(b.label))
)
.map((manifest) => {
const isSelected = manifest.id === currentId;
return (
<UnstyledButton
key={manifest.id}
onClick={() => handleSelect(manifest.id)}
>
<Stack gap={6} align="center">
<Box
w="100%"
p="0.125rem"
bd={
isSelected
? "0.125rem solid var(--mantine-color-bright)"
: "0.125rem solid transparent"
}
style={{
borderRadius: "calc(var(--mantine-radius-md) + 0.25rem)",
}}
>
<ThemePreview manifest={manifest} scheme={scheme} />
</Box>
<Text size="xs" fw={isSelected ? 700 : 400} ta="center">
{t(manifest.label)}
</Text>
</Stack>
</UnstyledButton>
);
})}
</SimpleGrid>
</Sheet>
</Box>
);
};
export default ThemePicker;
@@ -0,0 +1,179 @@
import {
DEFAULT_THEME,
convertCssVariables,
defaultCssVariablesResolver,
mergeMantineTheme,
mergeThemeOverrides,
} from "@mantine/core";
import { useEffect, useMemo, useState } from "react";
import { useLingui } from "@lingui/react/macro";
import { useAuth } from "@/contexts/auth-context";
import { baseTheme } from "@/lib/mantine/base-theme";
import type { ThemeManifest } from "@/themes/types";
/* Each preview is a same-origin srcdoc iframe carrying the theme's own
data-theme/-scheme, the app's stylesheets, the theme's font links, and its
Mantine variables — 1:1 with the active theme, with nothing bleeding in. */
const buildMantineVarsCss = (manifest: ThemeManifest, accent: string) => {
const merged = mergeMantineTheme(
DEFAULT_THEME,
mergeThemeOverrides(baseTheme, manifest.mantine ?? {}, {
primaryColor: accent,
})
);
return convertCssVariables(defaultCssVariablesResolver(merged), ":root");
};
const collectAppCss = () =>
Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
.filter(
(el) =>
!el.hasAttribute("data-mantine-styles") &&
!el.hasAttribute("data-flxn-theme-font")
)
.map((el) =>
el.tagName === "LINK"
? el.outerHTML
: `<style>${el.textContent ?? ""}</style>`
)
.join("\n");
const PREVIEW_LOCAL_CSS = `
html, body { margin: 0; height: 100%; overflow: hidden; }
body { display: flex; }
.pv-card {
flex: 1;
margin: 0.375rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
background-color: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-default);
box-shadow: var(--mantine-shadow-sm);
}
.pv-heading {
font-family: var(--mantine-font-family-headings);
color: var(--mantine-color-text);
font-size: 0.875rem;
font-weight: 700;
line-height: 1;
}
.pv-row { display: flex; align-items: center; gap: 4px; }
.pv-chip {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: var(--mantine-radius-default);
background-color: var(--mantine-primary-color-filled);
}
.pv-bars { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.pv-bar {
display: block;
height: 4px;
border-radius: 2px;
background-color: var(--mantine-color-dimmed);
}
`;
const buildSrcdoc = (
manifest: ThemeManifest,
scheme: "light" | "dark",
accent: string,
appCss: string
) => {
const fontLinks = (manifest.fontLinks ?? [])
.map((href) => `<link rel="stylesheet" href="${href}">`)
.join("");
return `<!doctype html>
<html data-theme="${manifest.id}" data-mantine-color-scheme="${scheme}">
<head>
<meta charset="utf-8">
<style>html { color-scheme: ${scheme}; }</style>
<style>${buildMantineVarsCss(manifest, accent)}</style>
${appCss}
${fontLinks}
<style>${PREVIEW_LOCAL_CSS}</style>
</head>
<body>
<div class="pv-card">
<span class="pv-heading">Ag</span>
<div class="pv-row">
<span class="pv-chip"></span>
<span class="pv-bars">
<span class="pv-bar" style="opacity: 0.7"></span>
<span class="pv-bar" style="opacity: 0.4; width: 60%"></span>
</span>
</div>
</div>
</body>
</html>`;
};
interface ThemePreviewProps {
manifest: ThemeManifest;
scheme: "light" | "dark";
height?: string;
/** Renders the document at height/scale and scales it down visually. */
scale?: number;
}
const ThemePreview = ({
manifest,
scheme,
height = "4.5rem",
scale = 1,
}: ThemePreviewProps) => {
const { metadata } = useAuth();
const { t } = useLingui();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const previewScheme =
manifest.colorSchemes === "both" ? scheme : manifest.colorSchemes;
const accent = metadata.accentColor || "blue";
const srcdoc = useMemo(
() =>
mounted
? buildSrcdoc(manifest, previewScheme, accent, collectAppCss())
: null,
[mounted, manifest, previewScheme, accent]
);
const frameStyle: React.CSSProperties = {
width: "100%",
height,
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-md)",
overflow: "hidden",
};
if (!srcdoc) return <div style={frameStyle} />;
return (
<div style={frameStyle}>
<iframe
srcDoc={srcdoc}
title={t(manifest.label)}
sandbox=""
scrolling="no"
aria-hidden
tabIndex={-1}
style={{
display: "block",
border: 0,
width: `${100 / scale}%`,
height: `calc(${height} / ${scale})`,
transform: `scale(${scale})`,
transformOrigin: "top left",
pointerEvents: "none",
}}
/>
</div>
);
};
export default ThemePreview;
+45
View File
@@ -2,12 +2,28 @@ import { createServerFn } from "@tanstack/react-start";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
// Whitelists: these values flow into Mantine's primaryColor / the
// data-mantine-color-scheme attribute — an unknown value crash-loops or
// unstyles the account's UI on every load.
const ACCENT_COLORS = [
"blue",
"red",
"green",
"yellow",
"grape",
"orange",
"pink",
"lime",
];
const COLOR_SCHEMES = ["light", "dark", "auto"];
export const updateUserColorScheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
if (!COLOR_SCHEMES.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
@@ -51,12 +67,41 @@ export const updateUserLocale = createServerFn({ method: "POST" })
};
});
export const updateUserTheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { THEME_IDS } = await import("@/themes/registry");
if (!THEME_IDS.includes(data)) {
throw new Error("Unknown theme");
}
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
theme: data,
});
return {
metadata: {
...metadata,
theme: data,
},
};
});
export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
if (!ACCENT_COLORS.includes(data)) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"