mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
use vite+
This commit is contained in:
+34
-34
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user