use vite+

This commit is contained in:
Amruth Pillai
2026-03-18 22:03:24 +01:00
parent d1dac8aeca
commit 192880e416
427 changed files with 58915 additions and 58759 deletions
+34 -34
View File
@@ -1,43 +1,43 @@
import type { ColorResult } from "@uiw/color-convert";
export function parseColorString(value: string): ColorResult["rgba"] | null {
const trimmed = value.trim();
const trimmed = value.trim();
// Parse rgb/rgba colors
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
// Parse rgb/rgba colors
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
if (rgbMatch) {
return {
r: Number.parseInt(rgbMatch[1] ?? "0", 10),
g: Number.parseInt(rgbMatch[2] ?? "0", 10),
b: Number.parseInt(rgbMatch[3] ?? "0", 10),
a: rgbMatch[4] ? Number.parseFloat(rgbMatch[4]) : 1,
};
}
if (rgbMatch) {
return {
r: Number.parseInt(rgbMatch[1] ?? "0", 10),
g: Number.parseInt(rgbMatch[2] ?? "0", 10),
b: Number.parseInt(rgbMatch[3] ?? "0", 10),
a: rgbMatch[4] ? Number.parseFloat(rgbMatch[4]) : 1,
};
}
// Parse hex colors (convert to RGB)
if (trimmed.startsWith("#")) {
const hexMatch = trimmed.match(/^#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$/i);
if (hexMatch) {
return {
r: Number.parseInt(hexMatch[1] ?? "0", 16),
g: Number.parseInt(hexMatch[2] ?? "0", 16),
b: Number.parseInt(hexMatch[3] ?? "0", 16),
a: 1,
};
}
// Parse hex colors (convert to RGB)
if (trimmed.startsWith("#")) {
const hexMatch = trimmed.match(/^#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$/i);
if (hexMatch) {
return {
r: Number.parseInt(hexMatch[1] ?? "0", 16),
g: Number.parseInt(hexMatch[2] ?? "0", 16),
b: Number.parseInt(hexMatch[3] ?? "0", 16),
a: 1,
};
}
// Support 3-digit hex
const hexMatch3 = trimmed.match(/^#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])$/i);
if (hexMatch3) {
return {
r: Number.parseInt((hexMatch3[1] ?? "0") + (hexMatch3[1] ?? "0"), 16),
g: Number.parseInt((hexMatch3[2] ?? "0") + (hexMatch3[2] ?? "0"), 16),
b: Number.parseInt((hexMatch3[3] ?? "0") + (hexMatch3[3] ?? "0"), 16),
a: 1,
};
}
}
// Support 3-digit hex
const hexMatch3 = trimmed.match(/^#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])$/i);
if (hexMatch3) {
return {
r: Number.parseInt((hexMatch3[1] ?? "0") + (hexMatch3[1] ?? "0"), 16),
g: Number.parseInt((hexMatch3[2] ?? "0") + (hexMatch3[2] ?? "0"), 16),
b: Number.parseInt((hexMatch3[3] ?? "0") + (hexMatch3[3] ?? "0"), 16),
a: 1,
};
}
}
return null;
return null;
}
+57 -57
View File
@@ -2,72 +2,72 @@ import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
clientPrefix: "VITE_",
runtimeEnv: process.env,
emptyStringAsUndefined: true,
clientPrefix: "VITE_",
runtimeEnv: process.env,
emptyStringAsUndefined: true,
client: {},
client: {},
server: {
// Server
TZ: z.string().default("Etc/UTC"),
APP_URL: z.url({ protocol: /https?/ }),
PRINTER_APP_URL: z.url({ protocol: /https?/ }).optional(),
server: {
// Server
TZ: z.string().default("Etc/UTC"),
APP_URL: z.url({ protocol: /https?/ }),
PRINTER_APP_URL: z.url({ protocol: /https?/ }).optional(),
// Printer
PRINTER_ENDPOINT: z.url({ protocol: /^(wss?|https?)$/ }),
// Printer
PRINTER_ENDPOINT: z.url({ protocol: /^(wss?|https?)$/ }),
// Database
DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),
// Database
DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),
// Authentication
AUTH_SECRET: z.string().min(1),
BETTER_AUTH_API_KEY: z.string().min(1).optional(),
// Authentication
AUTH_SECRET: z.string().min(1),
BETTER_AUTH_API_KEY: z.string().min(1).optional(),
// Social Auth (Google)
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (Google)
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (GitHub)
GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (GitHub)
GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
// Custom OAuth Provider
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
OAUTH_CLIENT_ID: z.string().min(1).optional(),
OAUTH_CLIENT_SECRET: z.string().min(1).optional(),
OAUTH_DISCOVERY_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_SCOPES: z
.string()
.min(1)
.transform((value) => value.split(" "))
.default(["openid", "profile", "email"]),
// Custom OAuth Provider
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
OAUTH_CLIENT_ID: z.string().min(1).optional(),
OAUTH_CLIENT_SECRET: z.string().min(1).optional(),
OAUTH_DISCOVERY_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_SCOPES: z
.string()
.min(1)
.transform((value) => value.split(" "))
.default(["openid", "profile", "email"]),
// Email (SMTP)
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
SMTP_SECURE: z.stringbool().default(false),
// Email (SMTP)
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
SMTP_SECURE: z.stringbool().default(false),
// Storage (Optional)
S3_ACCESS_KEY_ID: z.string().min(1).optional(),
S3_SECRET_ACCESS_KEY: z.string().min(1).optional(),
S3_REGION: z.string().default("us-east-1"),
S3_ENDPOINT: z.url({ protocol: /https?/ }).optional(),
S3_BUCKET: z.string().min(1).optional(),
// Set to "true" for path-style URLs (endpoint/bucket), common with MinIO, SeaweedFS, etc.
// Set to "false" for virtual-hosted-style URLs (bucket.endpoint), common with AWS S3, Cloudflare R2, etc.
S3_FORCE_PATH_STYLE: z.stringbool().default(false),
// Storage (Optional)
S3_ACCESS_KEY_ID: z.string().min(1).optional(),
S3_SECRET_ACCESS_KEY: z.string().min(1).optional(),
S3_REGION: z.string().default("us-east-1"),
S3_ENDPOINT: z.url({ protocol: /https?/ }).optional(),
S3_BUCKET: z.string().min(1).optional(),
// Set to "true" for path-style URLs (endpoint/bucket), common with MinIO, SeaweedFS, etc.
// Set to "false" for virtual-hosted-style URLs (bucket.endpoint), common with AWS S3, Cloudflare R2, etc.
S3_FORCE_PATH_STYLE: z.stringbool().default(false),
// Feature Flags
FLAG_DEBUG_PRINTER: z.stringbool().default(false),
FLAG_DISABLE_SIGNUPS: z.stringbool().default(false),
FLAG_DISABLE_EMAIL_AUTH: z.stringbool().default(false),
FLAG_DISABLE_IMAGE_PROCESSING: z.stringbool().default(false),
},
// Feature Flags
FLAG_DEBUG_PRINTER: z.stringbool().default(false),
FLAG_DISABLE_SIGNUPS: z.stringbool().default(false),
FLAG_DISABLE_EMAIL_AUTH: z.stringbool().default(false),
FLAG_DISABLE_IMAGE_PROCESSING: z.stringbool().default(false),
},
});
+22 -22
View File
@@ -1,41 +1,41 @@
import { slugify } from "./string";
function getReadableTimestamp(now: Date) {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const h = String(now.getHours()).padStart(2, "0");
const min = String(now.getMinutes()).padStart(2, "0");
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const h = String(now.getHours()).padStart(2, "0");
const min = String(now.getMinutes()).padStart(2, "0");
return `${y}${m}${d}_${h}${min}`;
return `${y}${m}${d}_${h}${min}`;
}
export function generateFilename(prefix: string, extension?: string) {
const now = new Date();
const name = slugify(prefix);
const timestamp = getReadableTimestamp(now);
const now = new Date();
const name = slugify(prefix);
const timestamp = getReadableTimestamp(now);
return `${name}_${timestamp}${extension ? `.${extension}` : ""}`;
return `${name}_${timestamp}${extension ? `.${extension}` : ""}`;
}
export function downloadWithAnchor(blob: Blob, filename: string) {
const a = document.createElement("a");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const url = URL.createObjectURL(blob);
a.href = url;
a.rel = "noopener";
a.download = filename;
a.href = url;
a.rel = "noopener";
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
export async function downloadFromUrl(url: string, filename: string) {
const response = await fetch(url);
const blob = await response.blob();
const response = await fetch(url);
const blob = await response.blob();
downloadWithAnchor(blob, filename);
downloadWithAnchor(blob, filename);
}
+140 -140
View File
@@ -6,61 +6,61 @@ import Cookies from "js-cookie";
import z from "zod";
const localeSchema = z.union([
z.literal("af-ZA"),
z.literal("am-ET"),
z.literal("ar-SA"),
z.literal("az-AZ"),
z.literal("bg-BG"),
z.literal("bn-BD"),
z.literal("ca-ES"),
z.literal("cs-CZ"),
z.literal("da-DK"),
z.literal("de-DE"),
z.literal("el-GR"),
z.literal("en-US"),
z.literal("en-GB"),
z.literal("es-ES"),
z.literal("fa-IR"),
z.literal("fi-FI"),
z.literal("fr-FR"),
z.literal("he-IL"),
z.literal("hi-IN"),
z.literal("hu-HU"),
z.literal("id-ID"),
z.literal("it-IT"),
z.literal("ja-JP"),
z.literal("km-KH"),
z.literal("kn-IN"),
z.literal("ko-KR"),
z.literal("lt-LT"),
z.literal("lv-LV"),
z.literal("ml-IN"),
z.literal("mr-IN"),
z.literal("ms-MY"),
z.literal("ne-NP"),
z.literal("nl-NL"),
z.literal("no-NO"),
z.literal("or-IN"),
z.literal("pl-PL"),
z.literal("pt-BR"),
z.literal("pt-PT"),
z.literal("ro-RO"),
z.literal("ru-RU"),
z.literal("sk-SK"),
z.literal("sl-SI"),
z.literal("sq-AL"),
z.literal("sr-SP"),
z.literal("sv-SE"),
z.literal("ta-IN"),
z.literal("te-IN"),
z.literal("th-TH"),
z.literal("tr-TR"),
z.literal("uk-UA"),
z.literal("uz-UZ"),
z.literal("vi-VN"),
z.literal("zh-CN"),
z.literal("zh-TW"),
z.literal("zu-ZA"),
z.literal("af-ZA"),
z.literal("am-ET"),
z.literal("ar-SA"),
z.literal("az-AZ"),
z.literal("bg-BG"),
z.literal("bn-BD"),
z.literal("ca-ES"),
z.literal("cs-CZ"),
z.literal("da-DK"),
z.literal("de-DE"),
z.literal("el-GR"),
z.literal("en-US"),
z.literal("en-GB"),
z.literal("es-ES"),
z.literal("fa-IR"),
z.literal("fi-FI"),
z.literal("fr-FR"),
z.literal("he-IL"),
z.literal("hi-IN"),
z.literal("hu-HU"),
z.literal("id-ID"),
z.literal("it-IT"),
z.literal("ja-JP"),
z.literal("km-KH"),
z.literal("kn-IN"),
z.literal("ko-KR"),
z.literal("lt-LT"),
z.literal("lv-LV"),
z.literal("ml-IN"),
z.literal("mr-IN"),
z.literal("ms-MY"),
z.literal("ne-NP"),
z.literal("nl-NL"),
z.literal("no-NO"),
z.literal("or-IN"),
z.literal("pl-PL"),
z.literal("pt-BR"),
z.literal("pt-PT"),
z.literal("ro-RO"),
z.literal("ru-RU"),
z.literal("sk-SK"),
z.literal("sl-SI"),
z.literal("sq-AL"),
z.literal("sr-SP"),
z.literal("sv-SE"),
z.literal("ta-IN"),
z.literal("te-IN"),
z.literal("th-TH"),
z.literal("tr-TR"),
z.literal("uk-UA"),
z.literal("uz-UZ"),
z.literal("vi-VN"),
z.literal("zh-CN"),
z.literal("zh-TW"),
z.literal("zu-ZA"),
]);
export type Locale = z.infer<typeof localeSchema>;
@@ -69,105 +69,105 @@ const storageKey = "locale";
const defaultLocale: Locale = "en-US";
export const localeMap = {
"af-ZA": msg`Afrikaans`,
"am-ET": msg`Amharic`,
"ar-SA": msg`Arabic`,
"az-AZ": msg`Azerbaijani`,
"bg-BG": msg`Bulgarian`,
"bn-BD": msg`Bengali`,
"ca-ES": msg`Catalan`,
"cs-CZ": msg`Czech`,
"da-DK": msg`Danish`,
"de-DE": msg`German`,
"el-GR": msg`Greek`,
"en-US": msg`English`,
"en-GB": msg`English (United Kingdom)`,
"es-ES": msg`Spanish`,
"fa-IR": msg`Persian`,
"fi-FI": msg`Finnish`,
"fr-FR": msg`French`,
"he-IL": msg`Hebrew`,
"hi-IN": msg`Hindi`,
"hu-HU": msg`Hungarian`,
"id-ID": msg`Indonesian`,
"it-IT": msg`Italian`,
"ja-JP": msg`Japanese`,
"km-KH": msg`Khmer`,
"kn-IN": msg`Kannada`,
"ko-KR": msg`Korean`,
"lt-LT": msg`Lithuanian`,
"lv-LV": msg`Latvian`,
"ml-IN": msg`Malayalam`,
"mr-IN": msg`Marathi`,
"ms-MY": msg`Malay`,
"ne-NP": msg`Nepali`,
"nl-NL": msg`Dutch`,
"no-NO": msg`Norwegian`,
"or-IN": msg`Odia`,
"pl-PL": msg`Polish`,
"pt-BR": msg`Portuguese (Brazil)`,
"pt-PT": msg`Portuguese (Portugal)`,
"ro-RO": msg`Romanian`,
"ru-RU": msg`Russian`,
"sk-SK": msg`Slovak`,
"sl-SI": msg`Slovenian`,
"sq-AL": msg`Albanian`,
"sr-SP": msg`Serbian`,
"sv-SE": msg`Swedish`,
"ta-IN": msg`Tamil`,
"te-IN": msg`Telugu`,
"th-TH": msg`Thai`,
"tr-TR": msg`Turkish`,
"uk-UA": msg`Ukrainian`,
"uz-UZ": msg`Uzbek`,
"vi-VN": msg`Vietnamese`,
"zh-CN": msg`Chinese (Simplified)`,
"zh-TW": msg`Chinese (Traditional)`,
"zu-ZA": msg`Zulu`,
"af-ZA": msg`Afrikaans`,
"am-ET": msg`Amharic`,
"ar-SA": msg`Arabic`,
"az-AZ": msg`Azerbaijani`,
"bg-BG": msg`Bulgarian`,
"bn-BD": msg`Bengali`,
"ca-ES": msg`Catalan`,
"cs-CZ": msg`Czech`,
"da-DK": msg`Danish`,
"de-DE": msg`German`,
"el-GR": msg`Greek`,
"en-US": msg`English`,
"en-GB": msg`English (United Kingdom)`,
"es-ES": msg`Spanish`,
"fa-IR": msg`Persian`,
"fi-FI": msg`Finnish`,
"fr-FR": msg`French`,
"he-IL": msg`Hebrew`,
"hi-IN": msg`Hindi`,
"hu-HU": msg`Hungarian`,
"id-ID": msg`Indonesian`,
"it-IT": msg`Italian`,
"ja-JP": msg`Japanese`,
"km-KH": msg`Khmer`,
"kn-IN": msg`Kannada`,
"ko-KR": msg`Korean`,
"lt-LT": msg`Lithuanian`,
"lv-LV": msg`Latvian`,
"ml-IN": msg`Malayalam`,
"mr-IN": msg`Marathi`,
"ms-MY": msg`Malay`,
"ne-NP": msg`Nepali`,
"nl-NL": msg`Dutch`,
"no-NO": msg`Norwegian`,
"or-IN": msg`Odia`,
"pl-PL": msg`Polish`,
"pt-BR": msg`Portuguese (Brazil)`,
"pt-PT": msg`Portuguese (Portugal)`,
"ro-RO": msg`Romanian`,
"ru-RU": msg`Russian`,
"sk-SK": msg`Slovak`,
"sl-SI": msg`Slovenian`,
"sq-AL": msg`Albanian`,
"sr-SP": msg`Serbian`,
"sv-SE": msg`Swedish`,
"ta-IN": msg`Tamil`,
"te-IN": msg`Telugu`,
"th-TH": msg`Thai`,
"tr-TR": msg`Turkish`,
"uk-UA": msg`Ukrainian`,
"uz-UZ": msg`Uzbek`,
"vi-VN": msg`Vietnamese`,
"zh-CN": msg`Chinese (Simplified)`,
"zh-TW": msg`Chinese (Traditional)`,
"zu-ZA": msg`Zulu`,
} satisfies Record<Locale, MessageDescriptor>;
export function isLocale(locale: string): locale is Locale {
return localeSchema.safeParse(locale).success;
return localeSchema.safeParse(locale).success;
}
const RTL_LANGUAGES = new Set([
"ar", // Arabic
"ckb", // Kurdish (Sorani)
"dv", // Dhivehi
"fa", // Persian
"he", // Hebrew
"ps", // Pashto
"sd", // Sindhi
"ug", // Uyghur
"ur", // Urdu
"yi", // Yiddish
"ar", // Arabic
"ckb", // Kurdish (Sorani)
"dv", // Dhivehi
"fa", // Persian
"he", // Hebrew
"ps", // Pashto
"sd", // Sindhi
"ug", // Uyghur
"ur", // Urdu
"yi", // Yiddish
]);
export function isRTL(locale: string): boolean {
const language = locale.split("-")[0].toLowerCase();
return RTL_LANGUAGES.has(language);
const language = locale.split("-")[0].toLowerCase();
return RTL_LANGUAGES.has(language);
}
export const getLocale = createIsomorphicFn()
.client(() => {
const locale = Cookies.get(storageKey);
if (!locale || !isLocale(locale)) return defaultLocale;
return locale;
})
.server(async () => {
const cookieLocale = getCookie(storageKey);
if (!cookieLocale || !isLocale(cookieLocale)) return defaultLocale;
return cookieLocale;
});
.client(() => {
const locale = Cookies.get(storageKey);
if (!locale || !isLocale(locale)) return defaultLocale;
return locale;
})
.server(async () => {
const cookieLocale = getCookie(storageKey);
if (!cookieLocale || !isLocale(cookieLocale)) return defaultLocale;
return cookieLocale;
});
export const setLocaleServerFn = createServerFn({ method: "POST" })
.inputValidator(localeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});
.inputValidator(localeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});
export const loadLocale = async (locale: string) => {
if (!isLocale(locale)) locale = defaultLocale;
const { messages } = await (import(`../../locales/${locale}.po`) as Promise<{ messages: Messages }>);
i18n.loadAndActivate({ locale, messages });
if (!isLocale(locale)) locale = defaultLocale;
const { messages } = await (import(`../../locales/${locale}.po`) as Promise<{ messages: Messages }>);
i18n.loadAndActivate({ locale, messages });
};
+50 -50
View File
@@ -1,73 +1,73 @@
type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown> & {
error?: unknown;
error?: unknown;
};
type SerializedError = {
name: string;
message: string;
stack?: string;
name: string;
message: string;
stack?: string;
};
function serializeError(error: unknown): SerializedError | undefined {
if (!error) return undefined;
if (!error) return undefined;
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
if (typeof error === "string") {
return {
name: "Error",
message: error,
};
}
if (typeof error === "string") {
return {
name: "Error",
message: error,
};
}
if (typeof error === "object") {
return {
name: "Error",
message: JSON.stringify(error),
};
}
if (typeof error === "object") {
return {
name: "Error",
message: JSON.stringify(error),
};
}
return {
name: "Error",
message: String(error as string),
};
return {
name: "Error",
message: String(error as string),
};
}
function log(level: LogLevel, message: string, context: LogContext = {}) {
const { error, ...rest } = context;
const payload = {
ts: new Date().toISOString(),
level,
message,
...rest,
error: serializeError(error),
};
const json = JSON.stringify(payload);
const { error, ...rest } = context;
const payload = {
ts: new Date().toISOString(),
level,
message,
...rest,
error: serializeError(error),
};
const json = JSON.stringify(payload);
if (level === "error") {
console.error(json);
return;
}
if (level === "error") {
console.error(json);
return;
}
if (level === "warn") {
console.warn(json);
return;
}
if (level === "warn") {
console.warn(json);
return;
}
console.log(json);
console.log(json);
}
export const logger = {
debug: (message: string, context?: LogContext) => log("debug", message, context),
info: (message: string, context?: LogContext) => log("info", message, context),
warn: (message: string, context?: LogContext) => log("warn", message, context),
error: (message: string, context?: LogContext) => log("error", message, context),
debug: (message: string, context?: LogContext) => log("debug", message, context),
info: (message: string, context?: LogContext) => log("info", message, context),
warn: (message: string, context?: LogContext) => log("warn", message, context),
error: (message: string, context?: LogContext) => log("error", message, context),
};
+1 -1
View File
@@ -5,4 +5,4 @@ const SALT_ROUNDS = 10;
export const hashPassword = (password: string): Promise<string> => hash(password, SALT_ROUNDS);
export const verifyPassword = (password: string, passwordHash: string): Promise<boolean> =>
compare(password, passwordHash);
compare(password, passwordHash);
+26 -26
View File
@@ -10,14 +10,14 @@ const PRINTER_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
* Token format: base64(resumeId:timestamp).signature
*/
export const generatePrinterToken = createIsomorphicFn().server((resumeId: string) => {
const timestamp = Date.now();
const payload = `${resumeId}:${timestamp}`;
const payloadBase64 = Buffer.from(payload).toString("base64url");
const timestamp = Date.now();
const payload = `${resumeId}:${timestamp}`;
const payloadBase64 = Buffer.from(payload).toString("base64url");
// Create HMAC signature using AUTH_SECRET
const signature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
// Create HMAC signature using AUTH_SECRET
const signature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
return `${payloadBase64}.${signature}`;
return `${payloadBase64}.${signature}`;
});
/**
@@ -25,31 +25,31 @@ export const generatePrinterToken = createIsomorphicFn().server((resumeId: strin
* Throws an error if the token is invalid or expired.
*/
export const verifyPrinterToken = createIsomorphicFn().server((token: string) => {
const parts = token.split(".");
if (parts.length !== 2) throw new Error("Invalid token format");
const parts = token.split(".");
if (parts.length !== 2) throw new Error("Invalid token format");
const [payloadBase64, signature] = parts;
const [payloadBase64, signature] = parts;
// Verify signature
const expectedSignature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
// Verify signature
const expectedSignature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new Error("Invalid token signature");
}
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new Error("Invalid token signature");
}
// Decode payload
const payload = Buffer.from(payloadBase64, "base64url").toString("utf-8");
const [resumeId, timestampStr] = payload.split(":");
if (!resumeId || !timestampStr) throw new Error("Invalid token payload");
// Decode payload
const payload = Buffer.from(payloadBase64, "base64url").toString("utf-8");
const [resumeId, timestampStr] = payload.split(":");
if (!resumeId || !timestampStr) throw new Error("Invalid token payload");
const timestamp = Number.parseInt(timestampStr, 10);
if (Number.isNaN(timestamp)) throw new Error("Invalid timestamp");
const timestamp = Number.parseInt(timestampStr, 10);
if (Number.isNaN(timestamp)) throw new Error("Invalid timestamp");
// Check expiration
const age = Date.now() - timestamp;
if (age < 0 || age > PRINTER_TOKEN_TTL_MS) throw new Error("Token expired");
// Check expiration
const age = Date.now() - timestamp;
if (age < 0 || age > PRINTER_TOKEN_TTL_MS) throw new Error("Token expired");
return resumeId;
return resumeId;
});
+144 -144
View File
@@ -12,16 +12,16 @@ import { getSectionTitle as getDefaultSectionTitle } from "./section";
/** Target section that an item can be moved to */
type MoveTargetSection = {
sectionId: string;
sectionTitle: string;
/** Whether this is a standard section (true) or custom section (false) */
isStandard: boolean;
sectionId: string;
sectionTitle: string;
/** Whether this is a standard section (true) or custom section (false) */
isStandard: boolean;
};
/** Page with its compatible sections for the move menu */
type MoveTargetPage = {
pageIndex: number;
sections: MoveTargetSection[];
pageIndex: number;
sections: MoveTargetSection[];
};
// ============================================================================
@@ -33,22 +33,22 @@ type MoveTargetPage = {
* Standard sections have predefined keys like "experience", "education", etc.
*/
function isStandardSectionId(sectionId: string): sectionId is SectionType {
const standardSections: SectionType[] = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
const standardSections: SectionType[] = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
return standardSections.includes(sectionId as SectionType);
return standardSections.includes(sectionId as SectionType);
}
// ============================================================================
@@ -66,16 +66,16 @@ function isStandardSectionId(sectionId: string): sectionId is SectionType {
* @returns The section title
*/
export function getSourceSectionTitle(
resumeData: ResumeData,
type: CustomSectionType,
customSectionId?: string,
resumeData: ResumeData,
type: CustomSectionType,
customSectionId?: string,
): string {
if (customSectionId) {
const customSection = resumeData.customSections.find((s) => s.id === customSectionId);
return customSection?.title ?? getDefaultSectionTitle(type);
}
if (customSectionId) {
const customSection = resumeData.customSections.find((s) => s.id === customSectionId);
return customSection?.title ?? getDefaultSectionTitle(type);
}
return getDefaultSectionTitle(type);
return getDefaultSectionTitle(type);
}
/**
@@ -88,49 +88,49 @@ export function getSourceSectionTitle(
* @returns Array of pages with their compatible sections
*/
export function getCompatibleMoveTargets(
resumeData: ResumeData,
sourceType: CustomSectionType,
sourceSectionId: string | undefined,
resumeData: ResumeData,
sourceType: CustomSectionType,
sourceSectionId: string | undefined,
): MoveTargetPage[] {
const { pages } = resumeData.metadata.layout;
const result: MoveTargetPage[] = [];
const { pages } = resumeData.metadata.layout;
const result: MoveTargetPage[] = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
const page = pages[pageIndex];
const allSectionIds = [...page.main, ...page.sidebar];
const compatibleSections: MoveTargetSection[] = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
const page = pages[pageIndex];
const allSectionIds = [...page.main, ...page.sidebar];
const compatibleSections: MoveTargetSection[] = [];
for (const sectionId of allSectionIds) {
// Skip the source section itself
if (sectionId === sourceSectionId || (sourceSectionId === undefined && sectionId === sourceType)) {
continue;
}
for (const sectionId of allSectionIds) {
// Skip the source section itself
if (sectionId === sourceSectionId || (sourceSectionId === undefined && sectionId === sourceType)) {
continue;
}
// Check if it's a standard section with matching type
if (isStandardSectionId(sectionId) && sectionId === sourceType) {
compatibleSections.push({
sectionId,
sectionTitle: getDefaultSectionTitle(sectionId),
isStandard: true,
});
continue;
}
// Check if it's a standard section with matching type
if (isStandardSectionId(sectionId) && sectionId === sourceType) {
compatibleSections.push({
sectionId,
sectionTitle: getDefaultSectionTitle(sectionId),
isStandard: true,
});
continue;
}
// Check if it's a custom section with matching type
const customSection = resumeData.customSections.find((s) => s.id === sectionId);
if (customSection && customSection.type === sourceType) {
compatibleSections.push({
sectionId: customSection.id,
sectionTitle: customSection.title,
isStandard: false,
});
}
}
// Check if it's a custom section with matching type
const customSection = resumeData.customSections.find((s) => s.id === sectionId);
if (customSection && customSection.type === sourceType) {
compatibleSections.push({
sectionId: customSection.id,
sectionTitle: customSection.title,
isStandard: false,
});
}
}
result.push({ pageIndex, sections: compatibleSections });
}
result.push({ pageIndex, sections: compatibleSections });
}
return result;
return result;
}
/**
@@ -143,31 +143,31 @@ export function getCompatibleMoveTargets(
* @returns The removed item, or null if not found
*/
export function removeItemFromSource(
draft: WritableDraft<ResumeData>,
itemId: string,
type: CustomSectionType,
customSectionId?: string,
draft: WritableDraft<ResumeData>,
itemId: string,
type: CustomSectionType,
customSectionId?: string,
): SectionItem | null {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return null;
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return null;
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
/**
@@ -179,25 +179,25 @@ export function removeItemFromSource(
* @param type - The section type
*/
export function addItemToSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
targetSectionId: string,
type: CustomSectionType,
draft: WritableDraft<ResumeData>,
item: SectionItem,
targetSectionId: string,
type: CustomSectionType,
): void {
// Check if target is a standard section
if (isStandardSectionId(targetSectionId) && targetSectionId === type) {
const section = draft.sections[type as SectionType];
if ("items" in section) {
section.items.push(item as never);
}
return;
}
// Check if target is a standard section
if (isStandardSectionId(targetSectionId) && targetSectionId === type) {
const section = draft.sections[type as SectionType];
if ("items" in section) {
section.items.push(item as never);
}
return;
}
// Otherwise, it's a custom section
const customSection = draft.customSections.find((s) => s.id === targetSectionId);
if (customSection) {
customSection.items.push(item as never);
}
// Otherwise, it's a custom section
const customSection = draft.customSections.find((s) => s.id === targetSectionId);
if (customSection) {
customSection.items.push(item as never);
}
}
/**
@@ -211,33 +211,33 @@ export function addItemToSection(
* @returns The ID of the newly created custom section
*/
export function createCustomSectionWithItem(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: CustomSectionType,
sectionTitle: string,
targetPageIndex: number,
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: CustomSectionType,
sectionTitle: string,
targetPageIndex: number,
): string {
const newSectionId = generateId();
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Add the section to the target page's main column
const page = draft.metadata.layout.pages[targetPageIndex];
if (page) {
page.main.push(newSectionId);
}
// Add the section to the target page's main column
const page = draft.metadata.layout.pages[targetPageIndex];
if (page) {
page.main.push(newSectionId);
}
return newSectionId;
return newSectionId;
}
/**
@@ -249,29 +249,29 @@ export function createCustomSectionWithItem(
* @param sectionTitle - The title for the new custom section
*/
export function createPageWithSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: CustomSectionType,
sectionTitle: string,
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: CustomSectionType,
sectionTitle: string,
): void {
const newSectionId = generateId();
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Create the new page with the section in the main column
draft.metadata.layout.pages.push({
fullWidth: false,
main: [newSectionId],
sidebar: [],
});
// Create the new page with the section in the main column
draft.metadata.layout.pages.push({
fullWidth: false,
main: [newSectionId],
sidebar: [],
});
}
+55 -55
View File
@@ -11,12 +11,12 @@ import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
* validated at the request boundary rather than failing later at the `fast-json-patch` layer.
*/
export const jsonPatchOperationSchema = z.discriminatedUnion("op", [
z.object({ op: z.literal("add"), path: z.string(), value: z.any() }),
z.object({ op: z.literal("remove"), path: z.string() }),
z.object({ op: z.literal("replace"), path: z.string(), value: z.any() }),
z.object({ op: z.literal("move"), path: z.string(), from: z.string() }),
z.object({ op: z.literal("copy"), path: z.string(), from: z.string() }),
z.object({ op: z.literal("test"), path: z.string(), value: z.any() }),
z.object({ op: z.literal("add"), path: z.string(), value: z.any() }),
z.object({ op: z.literal("remove"), path: z.string() }),
z.object({ op: z.literal("replace"), path: z.string(), value: z.any() }),
z.object({ op: z.literal("move"), path: z.string(), from: z.string() }),
z.object({ op: z.literal("copy"), path: z.string(), from: z.string() }),
z.object({ op: z.literal("test"), path: z.string(), value: z.any() }),
]);
/**
@@ -24,20 +24,20 @@ export const jsonPatchOperationSchema = z.discriminatedUnion("op", [
* Contains only the relevant details -- never the full document tree.
*/
export class ResumePatchError extends Error {
/** The error code from `fast-json-patch`, e.g. `TEST_OPERATION_FAILED`. */
code: string;
/** The zero-based index of the failing operation in the operations array. */
index: number;
/** The operation object that caused the failure. */
operation: Operation;
/** The error code from `fast-json-patch`, e.g. `TEST_OPERATION_FAILED`. */
code: string;
/** The zero-based index of the failing operation in the operations array. */
index: number;
/** The operation object that caused the failure. */
operation: Operation;
constructor(code: string, message: string, index: number, operation: Operation) {
super(message);
this.name = "ResumePatchError";
this.code = code;
this.index = index;
this.operation = operation;
}
constructor(code: string, message: string, index: number, operation: Operation) {
super(message);
this.name = "ResumePatchError";
this.code = code;
this.index = index;
this.operation = operation;
}
}
/**
@@ -45,20 +45,20 @@ export class ResumePatchError extends Error {
* These are returned to the API consumer instead of the raw library output.
*/
const patchErrorMessages: Record<string, string> = {
SEQUENCE_NOT_AN_ARRAY: "Patch sequence must be an array.",
OPERATION_NOT_AN_OBJECT: "Operation is not an object.",
OPERATION_OP_INVALID: "Operation `op` property is not one of the operations defined in RFC 6902.",
OPERATION_PATH_INVALID: "Operation `path` property is not a valid JSON Pointer string.",
OPERATION_FROM_REQUIRED: "Operation `from` property is required for `move` and `copy` operations.",
OPERATION_VALUE_REQUIRED: "Operation `value` property is required for `add`, `replace`, and `test` operations.",
OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED:
"Operation `value` contains an `undefined` value, which is not valid in JSON.",
OPERATION_PATH_CANNOT_ADD: "Cannot perform an `add` operation at the desired path.",
OPERATION_PATH_UNRESOLVABLE: "Cannot perform the operation at a path that does not exist.",
OPERATION_FROM_UNRESOLVABLE: "Cannot perform the operation from a path that does not exist.",
OPERATION_PATH_ILLEGAL_ARRAY_INDEX: "Array index in path must be an unsigned base-10 integer.",
OPERATION_VALUE_OUT_OF_BOUNDS: "The specified array index is greater than the number of elements in the array.",
TEST_OPERATION_FAILED: "Test operation failed -- the value at the given path did not match the expected value.",
SEQUENCE_NOT_AN_ARRAY: "Patch sequence must be an array.",
OPERATION_NOT_AN_OBJECT: "Operation is not an object.",
OPERATION_OP_INVALID: "Operation `op` property is not one of the operations defined in RFC 6902.",
OPERATION_PATH_INVALID: "Operation `path` property is not a valid JSON Pointer string.",
OPERATION_FROM_REQUIRED: "Operation `from` property is required for `move` and `copy` operations.",
OPERATION_VALUE_REQUIRED: "Operation `value` property is required for `add`, `replace`, and `test` operations.",
OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED:
"Operation `value` contains an `undefined` value, which is not valid in JSON.",
OPERATION_PATH_CANNOT_ADD: "Cannot perform an `add` operation at the desired path.",
OPERATION_PATH_UNRESOLVABLE: "Cannot perform the operation at a path that does not exist.",
OPERATION_FROM_UNRESOLVABLE: "Cannot perform the operation from a path that does not exist.",
OPERATION_PATH_ILLEGAL_ARRAY_INDEX: "Array index in path must be an unsigned base-10 integer.",
OPERATION_VALUE_OUT_OF_BOUNDS: "The specified array index is greater than the number of elements in the array.",
TEST_OPERATION_FAILED: "Test operation failed -- the value at the given path did not match the expected value.",
};
/**
@@ -66,19 +66,19 @@ const patchErrorMessages: Record<string, string> = {
* The library doesn't export the class directly, so we duck-type it.
*/
function isJsonPatchError(error: unknown): error is JsonPatchError {
return error instanceof Error && "index" in error && "operation" in error;
return error instanceof Error && "index" in error && "operation" in error;
}
/**
* Converts a `JsonPatchError` into a clean `ResumePatchError` that omits the document tree.
*/
function toResumePatchError(error: JsonPatchError): ResumePatchError {
const code = error.name;
const message = patchErrorMessages[code] ?? error.message;
const index = error.index ?? 0;
const operation = error.operation as Operation;
const code = error.name;
const message = patchErrorMessages[code] ?? error.message;
const index = error.index ?? 0;
const operation = error.operation as Operation;
return new ResumePatchError(code, message, index, operation);
return new ResumePatchError(code, message, index, operation);
}
/**
@@ -101,24 +101,24 @@ function toResumePatchError(error: JsonPatchError): ResumePatchError {
* @throws {Error} If the patched document does not conform to the `ResumeData` schema.
*/
export function applyResumePatches(data: ResumeData, operations: Operation[]): ResumeData {
// Validate operations structurally before applying.
const validationError = jsonpatch.validate(operations, data);
if (validationError) throw toResumePatchError(validationError);
// Validate operations structurally before applying.
const validationError = jsonpatch.validate(operations, data);
if (validationError) throw toResumePatchError(validationError);
// Apply operations. applyPatch throws on `test` failures.
let patched: ResumeData;
// Apply operations. applyPatch throws on `test` failures.
let patched: ResumeData;
try {
const result = jsonpatch.applyPatch(data, operations, false, false);
patched = result.newDocument;
} catch (error: unknown) {
if (isJsonPatchError(error)) throw toResumePatchError(error);
throw error;
}
try {
const result = jsonpatch.applyPatch(data, operations, false, false);
patched = result.newDocument;
} catch (error: unknown) {
if (isJsonPatchError(error)) throw toResumePatchError(error);
throw error;
}
// Validate the result still conforms to ResumeData.
const parsed = resumeDataSchema.safeParse(patched);
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
// Validate the result still conforms to ResumeData.
const parsed = resumeDataSchema.safeParse(patched);
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
return parsed.data;
return parsed.data;
}
+138 -138
View File
@@ -1,34 +1,34 @@
import { t } from "@lingui/core/macro";
import {
ArticleIcon,
BooksIcon,
BriefcaseIcon,
CertificateIcon,
ChartLineIcon,
CodeSimpleIcon,
CompassToolIcon,
DiamondsFourIcon,
DownloadIcon,
EnvelopeSimpleIcon,
FileCssIcon,
FootballIcon,
GraduationCapIcon,
HandHeartIcon,
type IconProps,
ImageIcon,
InfoIcon,
LayoutIcon,
MessengerLogoIcon,
NotepadIcon,
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
ShareFatIcon,
StarIcon,
TextTIcon,
TranslateIcon,
TrophyIcon,
UserIcon,
ArticleIcon,
BooksIcon,
BriefcaseIcon,
CertificateIcon,
ChartLineIcon,
CodeSimpleIcon,
CompassToolIcon,
DiamondsFourIcon,
DownloadIcon,
EnvelopeSimpleIcon,
FileCssIcon,
FootballIcon,
GraduationCapIcon,
HandHeartIcon,
type IconProps,
ImageIcon,
InfoIcon,
LayoutIcon,
MessengerLogoIcon,
NotepadIcon,
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
ShareFatIcon,
StarIcon,
TextTIcon,
TranslateIcon,
TrophyIcon,
UserIcon,
} from "@phosphor-icons/react";
import { match } from "ts-pattern";
@@ -42,133 +42,133 @@ export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType
type CustomOnlyType = "cover-letter";
export type RightSidebarSection =
| "template"
| "layout"
| "typography"
| "design"
| "page"
| "css"
| "notes"
| "sharing"
| "statistics"
| "export"
| "information";
| "template"
| "layout"
| "typography"
| "design"
| "page"
| "css"
| "notes"
| "sharing"
| "statistics"
| "export"
| "information";
export type SidebarSection = LeftSidebarSection | RightSidebarSection;
export const leftSidebarSections: LeftSidebarSection[] = [
"picture",
"basics",
"summary",
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
"custom",
"picture",
"basics",
"summary",
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
"custom",
] as const;
export const rightSidebarSections: RightSidebarSection[] = [
"template",
"layout",
"typography",
"design",
"page",
"css",
"notes",
"sharing",
"statistics",
"export",
"information",
"template",
"layout",
"typography",
"design",
"page",
"css",
"notes",
"sharing",
"statistics",
"export",
"information",
] as const;
export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string => {
return (
match(type)
// Left Sidebar Sections
.with("picture", () => t`Picture`)
.with("basics", () => t`Basics`)
.with("summary", () => t`Summary`)
.with("profiles", () => t`Profiles`)
.with("experience", () => t`Experience`)
.with("education", () => t`Education`)
.with("projects", () => t`Projects`)
.with("skills", () => t`Skills`)
.with("languages", () => t`Languages`)
.with("interests", () => t`Interests`)
.with("awards", () => t`Awards`)
.with("certifications", () => t`Certifications`)
.with("publications", () => t`Publications`)
.with("volunteer", () => t`Volunteer`)
.with("references", () => t`References`)
.with("custom", () => t`Custom Sections`)
return (
match(type)
// Left Sidebar Sections
.with("picture", () => t`Picture`)
.with("basics", () => t`Basics`)
.with("summary", () => t`Summary`)
.with("profiles", () => t`Profiles`)
.with("experience", () => t`Experience`)
.with("education", () => t`Education`)
.with("projects", () => t`Projects`)
.with("skills", () => t`Skills`)
.with("languages", () => t`Languages`)
.with("interests", () => t`Interests`)
.with("awards", () => t`Awards`)
.with("certifications", () => t`Certifications`)
.with("publications", () => t`Publications`)
.with("volunteer", () => t`Volunteer`)
.with("references", () => t`References`)
.with("custom", () => t`Custom Sections`)
// Custom Section Types (not in main sidebar)
.with("cover-letter", () => t`Cover Letter`)
// Custom Section Types (not in main sidebar)
.with("cover-letter", () => t`Cover Letter`)
// Right Sidebar Sections
.with("template", () => t`Template`)
.with("layout", () => t`Layout`)
.with("typography", () => t`Typography`)
.with("design", () => t`Design`)
.with("page", () => t`Page`)
.with("css", () => t`Custom CSS`)
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
// Right Sidebar Sections
.with("template", () => t`Template`)
.with("layout", () => t`Layout`)
.with("typography", () => t`Typography`)
.with("design", () => t`Design`)
.with("page", () => t`Page`)
.with("css", () => t`Custom CSS`)
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
.exhaustive()
);
.exhaustive()
);
};
export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: IconProps): React.ReactNode => {
const iconProps = { ...props, className: cn("shrink-0", props?.className) };
const iconProps = { ...props, className: cn("shrink-0", props?.className) };
return (
match(type)
// Left Sidebar Sections
.with("picture", () => <ImageIcon {...iconProps} />)
.with("basics", () => <UserIcon {...iconProps} />)
.with("summary", () => <ArticleIcon {...iconProps} />)
.with("profiles", () => <MessengerLogoIcon {...iconProps} />)
.with("experience", () => <BriefcaseIcon {...iconProps} />)
.with("education", () => <GraduationCapIcon {...iconProps} />)
.with("projects", () => <CodeSimpleIcon {...iconProps} />)
.with("skills", () => <CompassToolIcon {...iconProps} />)
.with("languages", () => <TranslateIcon {...iconProps} />)
.with("interests", () => <FootballIcon {...iconProps} />)
.with("awards", () => <TrophyIcon {...iconProps} />)
.with("certifications", () => <CertificateIcon {...iconProps} />)
.with("publications", () => <BooksIcon {...iconProps} />)
.with("volunteer", () => <HandHeartIcon {...iconProps} />)
.with("references", () => <PhoneIcon {...iconProps} />)
.with("custom", () => <StarIcon {...iconProps} />)
return (
match(type)
// Left Sidebar Sections
.with("picture", () => <ImageIcon {...iconProps} />)
.with("basics", () => <UserIcon {...iconProps} />)
.with("summary", () => <ArticleIcon {...iconProps} />)
.with("profiles", () => <MessengerLogoIcon {...iconProps} />)
.with("experience", () => <BriefcaseIcon {...iconProps} />)
.with("education", () => <GraduationCapIcon {...iconProps} />)
.with("projects", () => <CodeSimpleIcon {...iconProps} />)
.with("skills", () => <CompassToolIcon {...iconProps} />)
.with("languages", () => <TranslateIcon {...iconProps} />)
.with("interests", () => <FootballIcon {...iconProps} />)
.with("awards", () => <TrophyIcon {...iconProps} />)
.with("certifications", () => <CertificateIcon {...iconProps} />)
.with("publications", () => <BooksIcon {...iconProps} />)
.with("volunteer", () => <HandHeartIcon {...iconProps} />)
.with("references", () => <PhoneIcon {...iconProps} />)
.with("custom", () => <StarIcon {...iconProps} />)
// Custom Section Types (not in main sidebar)
.with("cover-letter", () => <EnvelopeSimpleIcon {...iconProps} />)
// Custom Section Types (not in main sidebar)
.with("cover-letter", () => <EnvelopeSimpleIcon {...iconProps} />)
// Right Sidebar Sections
.with("template", () => <DiamondsFourIcon {...iconProps} />)
.with("layout", () => <LayoutIcon {...iconProps} />)
.with("typography", () => <TextTIcon {...iconProps} />)
.with("design", () => <PaletteIcon {...iconProps} />)
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
.with("css", () => <FileCssIcon {...iconProps} />)
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
// Right Sidebar Sections
.with("template", () => <DiamondsFourIcon {...iconProps} />)
.with("layout", () => <LayoutIcon {...iconProps} />)
.with("typography", () => <TextTIcon {...iconProps} />)
.with("design", () => <PaletteIcon {...iconProps} />)
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
.with("css", () => <FileCssIcon {...iconProps} />)
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
.exhaustive()
);
.exhaustive()
);
};
+79 -79
View File
@@ -6,59 +6,59 @@ import DOMPurify, { type Config } from "dompurify";
* while stripping all potentially dangerous content like scripts and event handlers.
*/
const RICH_TEXT_CONFIG: Config = {
// Allow safe HTML tags used by the TipTap editor
ALLOWED_TAGS: [
// Text formatting
"p",
"br",
"hr",
"span",
"div",
// Headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
// Text styling
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"mark",
"code",
"pre",
// Lists
"ul",
"ol",
"li",
// Tables
"table",
"thead",
"tbody",
"tfoot",
"tr",
"th",
"td",
"colgroup",
"col",
// Links
"a",
// Quotes
"blockquote",
],
// Allow safe attributes
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
// Only allow http and https protocols in links
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// Force all links to open in new tab with safe rel attributes
ADD_ATTR: ["target", "rel"],
// Don't allow data: URIs which can be used for XSS
ALLOW_DATA_ATTR: false,
// Allow safe HTML tags used by the TipTap editor
ALLOWED_TAGS: [
// Text formatting
"p",
"br",
"hr",
"span",
"div",
// Headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
// Text styling
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"mark",
"code",
"pre",
// Lists
"ul",
"ol",
"li",
// Tables
"table",
"thead",
"tbody",
"tfoot",
"tr",
"th",
"td",
"colgroup",
"col",
// Links
"a",
// Quotes
"blockquote",
],
// Allow safe attributes
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
// Only allow http and https protocols in links
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// Force all links to open in new tab with safe rel attributes
ADD_ATTR: ["target", "rel"],
// Don't allow data: URIs which can be used for XSS
ALLOW_DATA_ATTR: false,
};
/**
@@ -70,8 +70,8 @@ const RICH_TEXT_CONFIG: Config = {
* @returns Sanitized HTML string safe for rendering
*/
export function sanitizeHtml(html: string): string {
if (!html) return "";
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
if (!html) return "";
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
}
/**
@@ -82,32 +82,32 @@ export function sanitizeHtml(html: string): string {
* consider using a dedicated CSS parser/sanitizer library.
*/
export function sanitizeCss(css: string): string {
if (!css) return "";
if (!css) return "";
// Remove any JavaScript expressions
let sanitized = css
// Remove javascript: URLs
.replace(/javascript\s*:/gi, "")
// Remove expression() which can execute JS in older IE
.replace(/expression\s*\(/gi, "")
// Remove url() with data: or javascript:
.replace(/url\s*\(\s*["']?\s*(?:javascript|data):/gi, "url(")
// Remove behavior: property (IE-specific, can run scripts)
.replace(/behavior\s*:/gi, "")
// Remove -moz-binding (Firefox-specific, can run scripts)
.replace(/-moz-binding\s*:/gi, "")
// Remove @import with javascript or data URLs
.replace(/@import\s+(?:url\s*\()?["']?\s*(?:javascript|data):/gi, "");
// Remove any JavaScript expressions
let sanitized = css
// Remove javascript: URLs
.replace(/javascript\s*:/gi, "")
// Remove expression() which can execute JS in older IE
.replace(/expression\s*\(/gi, "")
// Remove url() with data: or javascript:
.replace(/url\s*\(\s*["']?\s*(?:javascript|data):/gi, "url(")
// Remove behavior: property (IE-specific, can run scripts)
.replace(/behavior\s*:/gi, "")
// Remove -moz-binding (Firefox-specific, can run scripts)
.replace(/-moz-binding\s*:/gi, "")
// Remove @import with javascript or data URLs
.replace(/@import\s+(?:url\s*\()?["']?\s*(?:javascript|data):/gi, "");
// Use DOMPurify to clean any HTML that might be in the CSS
sanitized = DOMPurify.sanitize(sanitized, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
}) as string;
// Use DOMPurify to clean any HTML that might be in the CSS
sanitized = DOMPurify.sanitize(sanitized, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
}) as string;
return sanitized;
return sanitized;
}
/**
@@ -117,5 +117,5 @@ export function sanitizeCss(css: string): string {
* @returns True if the value is a plain object, false otherwise.
*/
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+21 -21
View File
@@ -7,7 +7,7 @@ import { v7 as uuidv7 } from "uuid";
* @returns The generated ID.
*/
export function generateId() {
return uuidv7();
return uuidv7();
}
/** Slugifies a string, with some pre-defined options.
@@ -16,7 +16,7 @@ export function generateId() {
* @returns The slugified value.
*/
export function slugify(value: string) {
return _slugify(value, { decamelize: false });
return _slugify(value, { decamelize: false });
}
/**
@@ -25,12 +25,12 @@ export function slugify(value: string) {
* @returns The initials.
*/
export function getInitials(name: string) {
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
/**
@@ -39,11 +39,11 @@ export function getInitials(name: string) {
* @returns The transformed username.
*/
export function toUsername(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "")
.slice(0, 64);
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "")
.slice(0, 64);
}
/**
@@ -51,12 +51,12 @@ export function toUsername(value: string) {
* @returns The random name.
*/
export function generateRandomName() {
return uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals],
style: "capital",
separator: " ",
length: 3,
});
return uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals],
style: "capital",
separator: " ",
length: 3,
});
}
/**
@@ -65,6 +65,6 @@ export function generateRandomName() {
* @returns The text content without HTML tags.
*/
export function stripHtml(html: string | undefined): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}
+1 -1
View File
@@ -2,5 +2,5 @@ import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
+17 -17
View File
@@ -14,28 +14,28 @@ const storageKey = "theme";
const defaultTheme: Theme = "dark";
export const themeMap = {
light: msg`Light`,
dark: msg`Dark`,
light: msg`Light`,
dark: msg`Dark`,
} satisfies Record<Theme, MessageDescriptor>;
export function isTheme(theme: string): theme is Theme {
return themeSchema.safeParse(theme).success;
return themeSchema.safeParse(theme).success;
}
export const getTheme = createIsomorphicFn()
.client(() => {
const theme = Cookies.get(storageKey);
if (!theme || !isTheme(theme)) return defaultTheme;
return theme;
})
.server(async () => {
const cookieTheme = getCookie(storageKey);
if (!cookieTheme || !isTheme(cookieTheme)) return defaultTheme;
return cookieTheme;
});
.client(() => {
const theme = Cookies.get(storageKey);
if (!theme || !isTheme(theme)) return defaultTheme;
return theme;
})
.server(async () => {
const cookieTheme = getCookie(storageKey);
if (!cookieTheme || !isTheme(cookieTheme)) return defaultTheme;
return cookieTheme;
});
export const setThemeServerFn = createServerFn({ method: "POST" })
.inputValidator(themeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});
.inputValidator(themeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});