themes lol
This commit is contained in:
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user