Initial TicketTracker release

This commit is contained in:
michael committed 2026-08-05 10:55:30 +02:00
commit 125b1d22ff
128 files changed
+26208

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
// Client-side cookie utilities.
// These functions manage cookies in the browser only.
// Server actions handle cookie updates on the server side.
function writeClientCookie(serializedCookie: string) {
// biome-ignore lint/suspicious/noDocumentCookie: This project still uses document.cookie for broad browser support.
document.cookie = serializedCookie;
}
export function setClientCookie(key: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
writeClientCookie(`${key}=${value}; expires=${expires}; path=/`);
}
export function getClientCookie(key: string) {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${key}=`))
?.split("=")[1];
}
export function deleteClientCookie(key: string) {
writeClientCookie(`${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`);
}
+23
View File
@@ -0,0 +1,23 @@
const systemSans = {
variable: "[--font-geist:ui-sans-serif,system-ui,sans-serif] [--font-geist-mono:ui-monospace,SFMono-Regular,Menlo,monospace]",
};
export const fontRegistry = {
geist: {
label: "System",
font: systemSans,
},
} as const;
export type FontKey = keyof typeof fontRegistry;
export const fontKeys = Object.keys(fontRegistry) as FontKey[];
export const fontVars = Object.values(fontRegistry)
.map(({ font }) => font.variable)
.join(" ");
export const fontOptions = fontKeys.map((key) => ({
key,
label: fontRegistry[key].label,
}));
+19
View File
@@ -0,0 +1,19 @@
"use client";
export function setLocalStorageValue(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch (error) {
if (process.env.NODE_ENV !== "production") {
console.error("[localStorage] Failed to write value:", error);
}
}
}
export function getLocalStorageValue(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
+32
View File
@@ -0,0 +1,32 @@
// Sidebar Variant
const SIDEBAR_VARIANT_OPTIONS = [
{ label: "Sidebar", value: "sidebar" },
{ label: "Inset", value: "inset" },
{ label: "Floating", value: "floating" },
] as const;
export const SIDEBAR_VARIANT_VALUES = SIDEBAR_VARIANT_OPTIONS.map((v) => v.value);
export type SidebarVariant = (typeof SIDEBAR_VARIANT_VALUES)[number];
// Sidebar Collapsible
const SIDEBAR_COLLAPSIBLE_OPTIONS = [
{ label: "Icon", value: "icon" },
{ label: "Offcanvas", value: "offcanvas" },
] as const;
export const SIDEBAR_COLLAPSIBLE_VALUES = SIDEBAR_COLLAPSIBLE_OPTIONS.map((v) => v.value);
export type SidebarCollapsible = (typeof SIDEBAR_COLLAPSIBLE_VALUES)[number];
// Content Layout
const CONTENT_LAYOUT_OPTIONS = [
{ label: "Centered", value: "centered" },
{ label: "Full Width", value: "full-width" },
] as const;
export const CONTENT_LAYOUT_VALUES = CONTENT_LAYOUT_OPTIONS.map((v) => v.value);
export type ContentLayout = (typeof CONTENT_LAYOUT_VALUES)[number];
// Navbar Style
const NAVBAR_STYLE_OPTIONS = [
{ label: "Sticky", value: "sticky" },
{ label: "Scroll", value: "scroll" },
] as const;
export const NAVBAR_STYLE_VALUES = NAVBAR_STYLE_OPTIONS.map((v) => v.value);
export type NavbarStyle = (typeof NAVBAR_STYLE_VALUES)[number];
@@ -0,0 +1,17 @@
"use client";
import { PREFERENCE_REGISTRY, type PreferenceKey, type PreferenceValueMap } from "./preferences-config";
import type { ResolvedThemeMode } from "./theme";
import { applyThemeMode } from "./theme-utils";
export function applyPreference<K extends PreferenceKey>(
key: K,
value: PreferenceValueMap[K],
): ResolvedThemeMode | undefined {
if (key === "theme_mode") {
return applyThemeMode(value as PreferenceValueMap["theme_mode"]);
}
document.documentElement.setAttribute(PREFERENCE_REGISTRY[key].attribute, value);
return undefined;
}
@@ -0,0 +1,134 @@
/**
* How each preference should be saved.
*
* "client-cookie" → write cookie on the browser only.
* "server-cookie" → write cookie through a Server Action.
* "localStorage" → save only on the client (non-layout stuff).
* "none" → no saving, resets on reload.
*
* Layout-critical prefs (sidebar_variant / sidebar_collapsible)
* must stay consistent during SSR → so they can’t use localStorage.
* Others are flexible and can use any persistence.
*/
import { fontKeys } from "@/lib/fonts/registry";
import {
CONTENT_LAYOUT_VALUES,
NAVBAR_STYLE_VALUES,
SIDEBAR_COLLAPSIBLE_VALUES,
SIDEBAR_VARIANT_VALUES,
} from "./layout";
import { THEME_MODE_VALUES, THEME_PRESET_VALUES } from "./theme";
export type PreferencePersistence = "none" | "client-cookie" | "server-cookie" | "localStorage";
type LayoutPersistence = Exclude<PreferencePersistence, "localStorage">;
type PreferenceDefinition<
Values extends readonly string[],
Persistence extends PreferencePersistence,
Attribute extends `data-${string}`,
> = {
values: Values;
defaultValue: Values[number];
persistence: Persistence;
attribute: Attribute;
};
function definePreference<
const Values extends readonly string[],
const Persistence extends PreferencePersistence,
const Attribute extends `data-${string}`,
>(definition: PreferenceDefinition<Values, Persistence, Attribute>) {
return definition;
}
function defineSSRPreference<
const Values extends readonly string[],
const Persistence extends LayoutPersistence,
const Attribute extends `data-${string}`,
>(definition: PreferenceDefinition<Values, Persistence, Attribute>) {
return definition;
}
export const PREFERENCE_REGISTRY = {
theme_mode: definePreference({
values: THEME_MODE_VALUES,
defaultValue: "light",
persistence: "localStorage",
attribute: "data-theme-mode",
}),
theme_preset: definePreference({
values: THEME_PRESET_VALUES,
defaultValue: "default",
persistence: "client-cookie",
attribute: "data-theme-preset",
}),
font: definePreference({
values: fontKeys,
defaultValue: "geist",
persistence: "client-cookie",
attribute: "data-font",
}),
content_layout: definePreference({
values: CONTENT_LAYOUT_VALUES,
defaultValue: "centered",
persistence: "client-cookie",
attribute: "data-content-layout",
}),
navbar_style: definePreference({
values: NAVBAR_STYLE_VALUES,
defaultValue: "sticky",
persistence: "client-cookie",
attribute: "data-navbar-style",
}),
sidebar_variant: defineSSRPreference({
values: SIDEBAR_VARIANT_VALUES,
defaultValue: "sidebar",
persistence: "client-cookie",
attribute: "data-sidebar-variant",
}),
sidebar_collapsible: defineSSRPreference({
values: SIDEBAR_COLLAPSIBLE_VALUES,
defaultValue: "icon",
persistence: "client-cookie",
attribute: "data-sidebar-collapsible",
}),
} as const;
export type PreferenceKey = keyof typeof PREFERENCE_REGISTRY;
export type PreferenceValueMap = {
[K in PreferenceKey]: (typeof PREFERENCE_REGISTRY)[K]["values"][number];
};
export const PREFERENCE_KEYS = Object.freeze(Object.keys(PREFERENCE_REGISTRY) as PreferenceKey[]);
export function getPreferencePersistence(key: PreferenceKey): PreferencePersistence {
return PREFERENCE_REGISTRY[key].persistence;
}
export const PREFERENCE_DEFAULTS = Object.fromEntries(
PREFERENCE_KEYS.map((key) => [key, PREFERENCE_REGISTRY[key].defaultValue]),
) as PreferenceValueMap;
export function parsePreference<K extends PreferenceKey>(
key: K,
rawValue: string | null | undefined,
): PreferenceValueMap[K] {
const definition = PREFERENCE_REGISTRY[key];
const allowedValues = definition.values as readonly string[];
if (rawValue && allowedValues.includes(rawValue)) {
return rawValue as PreferenceValueMap[K];
}
return definition.defaultValue as PreferenceValueMap[K];
}
@@ -0,0 +1,35 @@
"use client";
import { setValueToCookie } from "@/server/server-actions";
import { setClientCookie } from "../cookie.client";
import { setLocalStorageValue } from "../local-storage.client";
import {
getPreferencePersistence,
type PreferenceKey,
type PreferencePersistence,
type PreferenceValueMap,
} from "./preferences-config";
async function persistByMode(mode: PreferencePersistence, key: string, value: string): Promise<void> {
switch (mode) {
case "none":
return;
case "client-cookie":
setClientCookie(key, value);
return;
case "server-cookie":
await setValueToCookie(key, value);
return;
case "localStorage":
setLocalStorageValue(key, value);
return;
}
}
export function persistPreference<K extends PreferenceKey>(key: K, value: PreferenceValueMap[K]): Promise<void> {
return persistByMode(getPreferencePersistence(key), key, value);
}
@@ -0,0 +1,38 @@
import type { ResolvedThemeMode, ThemeMode } from "./theme";
function resolveThemeMode(mode: ThemeMode): ResolvedThemeMode {
if (mode === "system") {
const prefersDark = typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)")?.matches;
return prefersDark ? "dark" : "light";
}
return mode === "dark" ? "dark" : "light";
}
export function applyThemeMode(mode: ThemeMode): ResolvedThemeMode {
const resolved = resolveThemeMode(mode);
const doc = document.documentElement;
doc.setAttribute("data-theme-mode", mode);
doc.classList.add("disable-transitions");
doc.classList.toggle("dark", resolved === "dark");
doc.style.colorScheme = resolved;
requestAnimationFrame(() => {
doc.classList.remove("disable-transitions");
});
return resolved;
}
export function subscribeToSystemTheme(onChange: (mode: ResolvedThemeMode) => void): () => void {
if (typeof window === "undefined") return () => undefined;
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!media) return () => undefined;
const listener = (event: MediaQueryListEvent) => {
onChange(event.matches ? "dark" : "light");
};
media.addEventListener("change", listener);
return () => {
media.removeEventListener("change", listener);
};
}
+52
View File
@@ -0,0 +1,52 @@
const THEME_MODE_OPTIONS = [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "System", value: "system" },
] as const;
export const THEME_MODE_VALUES = THEME_MODE_OPTIONS.map((o) => o.value);
export type ThemeMode = (typeof THEME_MODE_VALUES)[number];
export type ResolvedThemeMode = "light" | "dark";
// --- generated:themePresets:start ---
export const THEME_PRESET_OPTIONS = [
{
label: "Default",
value: "default",
primary: {
light: "oklch(0.205 0 0)",
dark: "oklch(0.922 0 0)",
},
},
{
label: "Brutalist",
value: "brutalist",
primary: {
light: "oklch(0.6489 0.237 26.9728)",
dark: "oklch(0.7044 0.1872 23.1858)",
},
},
{
label: "Soft Pop",
value: "soft-pop",
primary: {
light: "oklch(0.5106 0.2301 276.9656)",
dark: "oklch(0.6801 0.1583 276.9349)",
},
},
{
label: "Tangerine",
value: "tangerine",
primary: {
light: "oklch(0.64 0.17 36.44)",
dark: "oklch(0.64 0.17 36.44)",
},
},
] as const;
export const THEME_PRESET_VALUES = THEME_PRESET_OPTIONS.map((p) => p.value);
export type ThemePreset = (typeof THEME_PRESET_OPTIONS)[number]["value"];
// --- generated:themePresets:end ---
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}