Initial TicketTracker release
This commit is contained in:
commit
125b1d22ff
128 files changed
+26208
No files matched your search
@@ -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=/`);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 ---
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in new issue
Block a user