Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,159 @@
|
||||
export interface ThemeColors {
|
||||
// Sidebar specific
|
||||
sidebar?: string; // --sidebar
|
||||
sidebarBackground?: string; // --sidebar-background
|
||||
sidebarForeground?: string; // --sidebar-foreground
|
||||
sidebarPrimary?: string; // --sidebar-primary
|
||||
sidebarPrimaryForeground?: string; // --sidebar-primary-foreground
|
||||
sidebarAccent?: string; // --sidebar-accent
|
||||
sidebarAccentForeground?: string; // --sidebar-accent-foreground
|
||||
sidebarBorder?: string; // --sidebar-border
|
||||
sidebarRing?: string; // --sidebar-ring
|
||||
|
||||
// General theme colors
|
||||
background?: string; // --background
|
||||
foreground?: string; // --foreground
|
||||
|
||||
muted?: string; // --muted
|
||||
mutedForeground?: string; // --muted-foreground
|
||||
|
||||
popover?: string; // --popover
|
||||
popoverForeground?: string; // --popover-foreground
|
||||
|
||||
card?: string; // --card
|
||||
cardForeground?: string; // --card-foreground
|
||||
|
||||
border?: string; // --border
|
||||
input?: string; // --input
|
||||
|
||||
primary?: string; // --primary
|
||||
primaryForeground?: string; // --primary-foreground
|
||||
|
||||
secondary?: string; // --secondary
|
||||
secondaryForeground?: string; // --secondary-foreground
|
||||
|
||||
accent?: string; // --accent
|
||||
accentForeground?: string; // --accent-foreground
|
||||
|
||||
destructive?: string; // --destructive
|
||||
destructiveForeground?: string; // --destructive-foreground
|
||||
|
||||
ring?: string; // --ring
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
// Sidebar settings
|
||||
sidebarVariant?: "inset" | "sidebar";
|
||||
sidebarDarkMode?: boolean; // Force dark mode for sidebar even in light theme
|
||||
|
||||
// Theme colors (CSS variables)
|
||||
colors?: ThemeColors;
|
||||
|
||||
// Dark mode colors (CSS variables)
|
||||
darkColors?: ThemeColors;
|
||||
|
||||
// Other theme settings
|
||||
radius?: string; // Border radius (e.g., "0.5rem")
|
||||
fontFamily?: string; // Font family (e.g., "ui-sans-serif, system-ui, -apple-system...")
|
||||
}
|
||||
|
||||
export const defaultThemeConfig: ThemeConfig = {
|
||||
sidebarVariant: "inset",
|
||||
sidebarDarkMode: false,
|
||||
colors: {},
|
||||
radius: "0.5rem",
|
||||
};
|
||||
|
||||
export function parseThemeConfig(config: string | null | undefined): ThemeConfig {
|
||||
if (!config) return defaultThemeConfig;
|
||||
try {
|
||||
return JSON.parse(config) as ThemeConfig;
|
||||
} catch {
|
||||
return defaultThemeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyThemeConfig(config: ThemeConfig): string {
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
|
||||
export function getThemeStyle({ themeConfig, isDarkMode }: { themeConfig: ThemeConfig; isDarkMode: boolean }): Record<string, string> {
|
||||
// Prepare style object with theme CSS variables
|
||||
const themeStyle: Record<string, string> = {};
|
||||
|
||||
// Apply light mode colors
|
||||
if (themeConfig?.colors) {
|
||||
const colors = themeConfig.colors;
|
||||
// General theme colors
|
||||
if (colors.background) themeStyle["--background"] = colors.background;
|
||||
if (colors.foreground) themeStyle["--foreground"] = colors.foreground;
|
||||
if (colors.muted) themeStyle["--muted"] = colors.muted;
|
||||
if (colors.mutedForeground) themeStyle["--muted-foreground"] = colors.mutedForeground;
|
||||
if (colors.popover) themeStyle["--popover"] = colors.popover;
|
||||
if (colors.popoverForeground) themeStyle["--popover-foreground"] = colors.popoverForeground;
|
||||
if (colors.card) themeStyle["--card"] = colors.card;
|
||||
if (colors.cardForeground) themeStyle["--card-foreground"] = colors.cardForeground;
|
||||
if (colors.border) themeStyle["--border"] = colors.border;
|
||||
if (colors.input) themeStyle["--input"] = colors.input;
|
||||
if (colors.primary) themeStyle["--primary"] = colors.primary;
|
||||
if (colors.primaryForeground) themeStyle["--primary-foreground"] = colors.primaryForeground;
|
||||
if (colors.secondary) themeStyle["--secondary"] = colors.secondary;
|
||||
if (colors.secondaryForeground) themeStyle["--secondary-foreground"] = colors.secondaryForeground;
|
||||
if (colors.accent) themeStyle["--accent"] = colors.accent;
|
||||
if (colors.accentForeground) themeStyle["--accent-foreground"] = colors.accentForeground;
|
||||
if (colors.destructive) themeStyle["--destructive"] = colors.destructive;
|
||||
if (colors.destructiveForeground) themeStyle["--destructive-foreground"] = colors.destructiveForeground;
|
||||
if (colors.ring) themeStyle["--ring"] = colors.ring;
|
||||
|
||||
// Sidebar colors (applied at root level for global access)
|
||||
if (colors.sidebar) themeStyle["--sidebar"] = colors.sidebar;
|
||||
if (colors.sidebarBackground) themeStyle["--sidebar-background"] = colors.sidebarBackground;
|
||||
if (colors.sidebarForeground) themeStyle["--sidebar-foreground"] = colors.sidebarForeground;
|
||||
if (colors.sidebarPrimary) themeStyle["--sidebar-primary"] = colors.sidebarPrimary;
|
||||
if (colors.sidebarPrimaryForeground) themeStyle["--sidebar-primary-foreground"] = colors.sidebarPrimaryForeground;
|
||||
if (colors.sidebarAccent) themeStyle["--sidebar-accent"] = colors.sidebarAccent;
|
||||
if (colors.sidebarAccentForeground) themeStyle["--sidebar-accent-foreground"] = colors.sidebarAccentForeground;
|
||||
if (colors.sidebarBorder) themeStyle["--sidebar-border"] = colors.sidebarBorder;
|
||||
if (colors.sidebarRing) themeStyle["--sidebar-ring"] = colors.sidebarRing;
|
||||
}
|
||||
if (themeConfig?.radius) {
|
||||
themeStyle["--radius"] = themeConfig.radius;
|
||||
}
|
||||
if (themeConfig?.fontFamily) {
|
||||
themeStyle["font-family"] = themeConfig.fontFamily;
|
||||
}
|
||||
|
||||
if (isDarkMode && themeConfig?.darkColors) {
|
||||
const darkColors = themeConfig.darkColors;
|
||||
|
||||
if (darkColors.background) themeStyle["--background"] = darkColors.background;
|
||||
if (darkColors.foreground) themeStyle["--foreground"] = darkColors.foreground;
|
||||
if (darkColors.muted) themeStyle["--muted"] = darkColors.muted;
|
||||
if (darkColors.mutedForeground) themeStyle["--muted-foreground"] = darkColors.mutedForeground;
|
||||
if (darkColors.popover) themeStyle["--popover"] = darkColors.popover;
|
||||
if (darkColors.popoverForeground) themeStyle["--popover-foreground"] = darkColors.popoverForeground;
|
||||
if (darkColors.card) themeStyle["--card"] = darkColors.card;
|
||||
if (darkColors.cardForeground) themeStyle["--card-foreground"] = darkColors.cardForeground;
|
||||
if (darkColors.border) themeStyle["--border"] = darkColors.border;
|
||||
if (darkColors.input) themeStyle["--input"] = darkColors.input;
|
||||
if (darkColors.primary) themeStyle["--primary"] = darkColors.primary;
|
||||
if (darkColors.primaryForeground) themeStyle["--primary-foreground"] = darkColors.primaryForeground;
|
||||
if (darkColors.secondary) themeStyle["--secondary"] = darkColors.secondary;
|
||||
if (darkColors.secondaryForeground) themeStyle["--secondary-foreground"] = darkColors.secondaryForeground;
|
||||
if (darkColors.accent) themeStyle["--accent"] = darkColors.accent;
|
||||
if (darkColors.accentForeground) themeStyle["--accent-foreground"] = darkColors.accentForeground;
|
||||
if (darkColors.destructive) themeStyle["--destructive"] = darkColors.destructive;
|
||||
if (darkColors.destructiveForeground) themeStyle["--destructive-foreground"] = darkColors.destructiveForeground;
|
||||
if (darkColors.ring) themeStyle["--ring"] = darkColors.ring;
|
||||
if (darkColors.sidebar) themeStyle["--sidebar"] = darkColors.sidebar;
|
||||
if (darkColors.sidebarBackground) themeStyle["--sidebar-background"] = darkColors.sidebarBackground;
|
||||
if (darkColors.sidebarForeground) themeStyle["--sidebar-foreground"] = darkColors.sidebarForeground;
|
||||
if (darkColors.sidebarPrimary) themeStyle["--sidebar-primary"] = darkColors.sidebarPrimary;
|
||||
if (darkColors.sidebarPrimaryForeground) themeStyle["--sidebar-primary-foreground"] = darkColors.sidebarPrimaryForeground;
|
||||
if (darkColors.sidebarAccent) themeStyle["--sidebar-accent"] = darkColors.sidebarAccent;
|
||||
if (darkColors.sidebarAccentForeground) themeStyle["--sidebar-accent-foreground"] = darkColors.sidebarAccentForeground;
|
||||
if (darkColors.sidebarBorder) themeStyle["--sidebar-border"] = darkColors.sidebarBorder;
|
||||
if (darkColors.sidebarRing) themeStyle["--sidebar-ring"] = darkColors.sidebarRing;
|
||||
}
|
||||
return themeStyle;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ThemeConfig, ThemeColors } from "./ThemeConfig";
|
||||
|
||||
export function exportConfigToCss(config: ThemeConfig): string {
|
||||
let css = "";
|
||||
|
||||
// Export light mode colors
|
||||
if (config.colors && Object.keys(config.colors).length > 0) {
|
||||
css += ":root {\n";
|
||||
|
||||
// Add font family if present
|
||||
if (config.fontFamily) {
|
||||
css += ` --font-sans: ${config.fontFamily};\n`;
|
||||
}
|
||||
// Add radius if present
|
||||
if (config.radius) {
|
||||
css += ` --radius: ${config.radius};\n`;
|
||||
}
|
||||
|
||||
css += exportColors(config.colors);
|
||||
|
||||
css += "}\n";
|
||||
}
|
||||
|
||||
// Export dark mode colors
|
||||
if (config.darkColors && Object.keys(config.darkColors).length > 0) {
|
||||
if (css) css += "\n";
|
||||
css += ".dark {\n";
|
||||
|
||||
if (config.fontFamily) {
|
||||
css += ` --font-sans: ${config.fontFamily};\n`;
|
||||
}
|
||||
|
||||
// Add radius if different from light mode
|
||||
if (config.radius && !config.colors) {
|
||||
css += ` --radius: ${config.radius};\n`;
|
||||
}
|
||||
|
||||
css += exportColors(config.darkColors);
|
||||
|
||||
css += "}\n";
|
||||
}
|
||||
|
||||
// If no colors but has other config, export to :root
|
||||
if (!css && (config.radius || config.fontFamily)) {
|
||||
css += ":root {\n";
|
||||
if (config.fontFamily) {
|
||||
css += ` --font-sans: ${config.fontFamily};\n`;
|
||||
}
|
||||
if (config.radius) {
|
||||
css += ` --radius: ${config.radius};\n`;
|
||||
}
|
||||
css += "}\n";
|
||||
}
|
||||
|
||||
return css || "/* No custom theme configuration */";
|
||||
}
|
||||
|
||||
function exportColors(colors: ThemeColors): string {
|
||||
let css = "";
|
||||
|
||||
const colorMap: Record<keyof ThemeColors, string> = {
|
||||
background: "--background",
|
||||
foreground: "--foreground",
|
||||
card: "--card",
|
||||
cardForeground: "--card-foreground",
|
||||
popover: "--popover",
|
||||
popoverForeground: "--popover-foreground",
|
||||
primary: "--primary",
|
||||
primaryForeground: "--primary-foreground",
|
||||
secondary: "--secondary",
|
||||
secondaryForeground: "--secondary-foreground",
|
||||
muted: "--muted",
|
||||
mutedForeground: "--muted-foreground",
|
||||
accent: "--accent",
|
||||
accentForeground: "--accent-foreground",
|
||||
destructive: "--destructive",
|
||||
destructiveForeground: "--destructive-foreground",
|
||||
border: "--border",
|
||||
input: "--input",
|
||||
ring: "--ring",
|
||||
sidebar: "--sidebar",
|
||||
sidebarBackground: "--sidebar-background",
|
||||
sidebarForeground: "--sidebar-foreground",
|
||||
sidebarPrimary: "--sidebar-primary",
|
||||
sidebarPrimaryForeground: "--sidebar-primary-foreground",
|
||||
sidebarAccent: "--sidebar-accent",
|
||||
sidebarAccentForeground: "--sidebar-accent-foreground",
|
||||
sidebarBorder: "--sidebar-border",
|
||||
sidebarRing: "--sidebar-ring",
|
||||
};
|
||||
|
||||
for (const [key, cssVar] of Object.entries(colorMap)) {
|
||||
const value = colors[key as keyof ThemeColors];
|
||||
if (value) {
|
||||
css += ` ${cssVar}: ${value};\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ThemeConfig, ThemeColors } from "./ThemeConfig";
|
||||
|
||||
// Maps CSS variable names to our ThemeConfig keys
|
||||
const cssVarMap: Record<string, keyof ThemeColors> = {
|
||||
"--background": "background",
|
||||
"--foreground": "foreground",
|
||||
"--card": "card",
|
||||
"--card-foreground": "cardForeground",
|
||||
"--popover": "popover",
|
||||
"--popover-foreground": "popoverForeground",
|
||||
"--primary": "primary",
|
||||
"--primary-foreground": "primaryForeground",
|
||||
"--secondary": "secondary",
|
||||
"--secondary-foreground": "secondaryForeground",
|
||||
"--muted": "muted",
|
||||
"--muted-foreground": "mutedForeground",
|
||||
"--accent": "accent",
|
||||
"--accent-foreground": "accentForeground",
|
||||
"--destructive": "destructive",
|
||||
"--destructive-foreground": "destructiveForeground",
|
||||
"--border": "border",
|
||||
"--input": "input",
|
||||
"--ring": "ring",
|
||||
"--sidebar": "sidebar",
|
||||
"--sidebar-background": "sidebarBackground",
|
||||
"--sidebar-foreground": "sidebarForeground",
|
||||
"--sidebar-primary": "sidebarPrimary",
|
||||
"--sidebar-primary-foreground": "sidebarPrimaryForeground",
|
||||
"--sidebar-accent": "sidebarAccent",
|
||||
"--sidebar-accent-foreground": "sidebarAccentForeground",
|
||||
"--sidebar-border": "sidebarBorder",
|
||||
"--sidebar-ring": "sidebarRing",
|
||||
};
|
||||
|
||||
export function parseCssConfig(cssText: string): Partial<ThemeConfig> {
|
||||
const config: Partial<ThemeConfig> = {
|
||||
colors: {},
|
||||
darkColors: {},
|
||||
};
|
||||
|
||||
// Parse :root section for light mode colors
|
||||
const rootMatch = cssText.match(/:root\s*{([^}]*)}/);
|
||||
if (rootMatch) {
|
||||
const rootContent = rootMatch[1];
|
||||
config.colors = parseColorSection(rootContent);
|
||||
|
||||
// Parse radius
|
||||
const radiusMatch = rootContent.match(/--radius:\s*([^;]+);/);
|
||||
if (radiusMatch) {
|
||||
const radiusValue = radiusMatch[1].trim();
|
||||
// Convert px to rem (assuming 16px = 1rem)
|
||||
if (radiusValue.endsWith("px")) {
|
||||
const px = parseFloat(radiusValue);
|
||||
config.radius = `${px / 16}rem`;
|
||||
} else if (radiusValue.endsWith("rem")) {
|
||||
config.radius = radiusValue;
|
||||
} else {
|
||||
config.radius = radiusValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse font-sans (use as default font family)
|
||||
const fontMatch = rootContent.match(/--font-sans:\s*([^;]+);/);
|
||||
if (fontMatch) {
|
||||
config.fontFamily = fontMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse .dark section for dark mode colors
|
||||
const darkMatch = cssText.match(/\.dark\s*{([^}]*)}/);
|
||||
if (darkMatch) {
|
||||
const darkContent = darkMatch[1];
|
||||
config.darkColors = parseColorSection(darkContent);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseColorSection(content: string): ThemeColors {
|
||||
const colors: ThemeColors = {};
|
||||
|
||||
// Extract all CSS variables
|
||||
const varRegex = /(--[a-zA-Z-]+):\s*([^;]+);/g;
|
||||
let match;
|
||||
|
||||
while ((match = varRegex.exec(content)) !== null) {
|
||||
const cssVar = match[1];
|
||||
const value = match[2].trim();
|
||||
|
||||
// Check if this CSS variable maps to one of our theme colors
|
||||
const configKey = cssVarMap[cssVar];
|
||||
if (configKey) {
|
||||
colors[configKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function formatCssValue(value: string): string {
|
||||
// If it's already a valid CSS color format, return as is
|
||||
if (
|
||||
value.startsWith("hsl(") ||
|
||||
value.startsWith("hsla(") ||
|
||||
value.startsWith("oklch(") ||
|
||||
value.startsWith("rgb(") ||
|
||||
value.startsWith("rgba(") ||
|
||||
value.startsWith("#")
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Otherwise, assume it's a hex color without the #
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
return `#${value}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { defaultThemes } from "./defaultThemes";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readSettings(locale: "de" | "en") {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, `apps/web/public/locales/${locale}/settings.json`), "utf8")) as Record<string, string>;
|
||||
}
|
||||
|
||||
describe("default theme preset i18n", () => {
|
||||
it("keeps every visible preset label backed by English and German settings keys", () => {
|
||||
const en = readSettings("en");
|
||||
const de = readSettings("de");
|
||||
|
||||
for (const theme of defaultThemes) {
|
||||
expect(theme.labelKey).toBe(`settings:tenant.theme.presets.${theme.value}`);
|
||||
const localeKey = theme.labelKey.replace("settings:", "");
|
||||
expect(en[localeKey], `${theme.value} English label`).toBeTruthy();
|
||||
expect(de[localeKey], `${theme.value} German label`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/db/users.db.server", () => ({
|
||||
updateUserProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
const { normalizeLightOrDarkMode, nextLightOrDarkMode, persistUserLightOrDarkMode } = await import("./userThemePreference.server");
|
||||
const { updateUserProfile } = await import("~/utils/db/users.db.server");
|
||||
|
||||
describe("user theme preference helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(updateUserProfile).mockReset();
|
||||
});
|
||||
|
||||
it("accepts only light and dark values", () => {
|
||||
expect(normalizeLightOrDarkMode("light")).toBe("light");
|
||||
expect(normalizeLightOrDarkMode("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it("normalizes missing and unknown values to light", () => {
|
||||
expect(normalizeLightOrDarkMode(undefined)).toBe("light");
|
||||
expect(normalizeLightOrDarkMode(null)).toBe("light");
|
||||
expect(normalizeLightOrDarkMode("")).toBe("light");
|
||||
expect(normalizeLightOrDarkMode("system")).toBe("light");
|
||||
});
|
||||
|
||||
it("toggles from normalized current values", () => {
|
||||
expect(nextLightOrDarkMode("light")).toBe("dark");
|
||||
expect(nextLightOrDarkMode("dark")).toBe("light");
|
||||
expect(nextLightOrDarkMode("unknown")).toBe("dark");
|
||||
});
|
||||
|
||||
it("does not write for anonymous users", async () => {
|
||||
await expect(persistUserLightOrDarkMode(undefined, "dark")).resolves.toBeNull();
|
||||
await expect(persistUserLightOrDarkMode("", "dark")).resolves.toBeNull();
|
||||
|
||||
expect(updateUserProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a normalized value for authenticated users", async () => {
|
||||
const user = { id: "user-1" } as Awaited<ReturnType<typeof updateUserProfile>>;
|
||||
vi.mocked(updateUserProfile).mockResolvedValueOnce(user);
|
||||
|
||||
await expect(persistUserLightOrDarkMode("user-1", "dark")).resolves.toEqual(user);
|
||||
|
||||
expect(updateUserProfile).toHaveBeenCalledTimes(1);
|
||||
expect(updateUserProfile).toHaveBeenCalledWith({ lightOrDarkMode: "dark" }, "user-1");
|
||||
});
|
||||
|
||||
it("normalizes unknown values before persisting", async () => {
|
||||
vi.mocked(updateUserProfile).mockResolvedValueOnce({ id: "user-1" } as Awaited<ReturnType<typeof updateUserProfile>>);
|
||||
|
||||
await persistUserLightOrDarkMode("user-1", "system");
|
||||
|
||||
expect(updateUserProfile).toHaveBeenCalledWith({ lightOrDarkMode: "light" }, "user-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { updateUserProfile } from "~/utils/db/users.db.server";
|
||||
|
||||
export const LIGHT_OR_DARK_MODES = ["light", "dark"] as const;
|
||||
|
||||
export type LightOrDarkMode = (typeof LIGHT_OR_DARK_MODES)[number];
|
||||
|
||||
export function normalizeLightOrDarkMode(value: unknown): LightOrDarkMode {
|
||||
return value === "dark" ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function nextLightOrDarkMode(current: unknown): LightOrDarkMode {
|
||||
return normalizeLightOrDarkMode(current) === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export async function persistUserLightOrDarkMode(userId: string | null | undefined, lightOrDarkMode: unknown) {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await updateUserProfile({ lightOrDarkMode: normalizeLightOrDarkMode(lightOrDarkMode) }, userId);
|
||||
}
|
||||
Reference in new issue
Block a user