mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 01:44:53 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { auth } from "@reactive-resume/auth/config";
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { dashClient } from "@better-auth/infra/client";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import {
|
||||
adminClient,
|
||||
genericOAuthClient,
|
||||
inferAdditionalFields,
|
||||
twoFactorClient,
|
||||
usernameClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
const getAuthClient = () => {
|
||||
return createAuthClient({
|
||||
plugins: [
|
||||
dashClient(),
|
||||
adminClient(),
|
||||
apiKeyClient(),
|
||||
passkeyClient(),
|
||||
usernameClient(),
|
||||
twoFactorClient({
|
||||
onTwoFactorRedirect() {
|
||||
// Redirect to 2FA verification page
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/verify-2fa";
|
||||
}
|
||||
},
|
||||
}),
|
||||
genericOAuthClient(),
|
||||
oauthProviderClient(),
|
||||
oauthProviderResourceClient(),
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const authClient = getAuthClient();
|
||||
@@ -0,0 +1,25 @@
|
||||
// Isomorphic getSession.
|
||||
// On the server, calls Better Auth's server API directly (uses request headers).
|
||||
// On the client, calls the auth client's getSession over fetch.
|
||||
//
|
||||
// This lives in apps/web (not @reactive-resume/auth) because @reactive-resume/auth
|
||||
// is server-only — importing its `auth` instance into client code drags in
|
||||
// drizzle/pg/jose and triggers TanStack Start's client/server import-protection
|
||||
// (the "tanstack-start-injected-head-scripts" virtual-module error).
|
||||
|
||||
import type { AuthSession } from "@reactive-resume/auth/types";
|
||||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { authClient } from "./client";
|
||||
|
||||
export const getSession = createIsomorphicFn()
|
||||
.client(async (): Promise<AuthSession | null> => {
|
||||
const { data, error } = await authClient.getSession();
|
||||
if (error) return null;
|
||||
return data as AuthSession;
|
||||
})
|
||||
.server(async (): Promise<AuthSession | null> => {
|
||||
const result = await auth.api.getSession({ headers: getRequestHeaders() });
|
||||
return result as AuthSession | null;
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
export function getReadableErrorMessage(error: unknown, fallback: string): string {
|
||||
if (typeof error === "string" && error) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type ErrorMessageByCode = Record<string, string>;
|
||||
|
||||
export function getOrpcErrorMessage(
|
||||
error: unknown,
|
||||
options: {
|
||||
fallback: string;
|
||||
byCode?: ErrorMessageByCode;
|
||||
allowServerMessage?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (!(error instanceof ORPCError)) return getReadableErrorMessage(error, options.fallback);
|
||||
|
||||
const mappedMessage = options.byCode?.[error.code];
|
||||
if (mappedMessage) return mappedMessage;
|
||||
|
||||
if (options.allowServerMessage && error.message) return error.message;
|
||||
return options.fallback;
|
||||
}
|
||||
|
||||
export function getResumeErrorMessage(error: unknown): string {
|
||||
return getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
RESUME_SLUG_ALREADY_EXISTS: "A resume with this slug already exists.",
|
||||
RESUME_LOCKED: "This resume is locked. Unlock it first to make changes.",
|
||||
},
|
||||
fallback: "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { MessageDescriptor, Messages } from "@lingui/core";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { createIsomorphicFn, createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
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"),
|
||||
]);
|
||||
|
||||
export type Locale = z.infer<typeof localeSchema>;
|
||||
|
||||
const storageKey = "locale";
|
||||
const defaultLocale: Locale = "en-US";
|
||||
const messageLoaders = import.meta.glob<{ messages: Messages }>("../../locales/*.po");
|
||||
|
||||
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`,
|
||||
} satisfies Record<Locale, MessageDescriptor>;
|
||||
|
||||
export function isLocale(locale: string): locale is Locale {
|
||||
return localeSchema.safeParse(locale).success;
|
||||
}
|
||||
|
||||
export const resolveLocale = (locale: string): Locale => {
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
};
|
||||
|
||||
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
|
||||
]);
|
||||
|
||||
export function isRTL(locale: string): boolean {
|
||||
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;
|
||||
});
|
||||
|
||||
export const setLocaleServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(localeSchema)
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(storageKey, data);
|
||||
});
|
||||
|
||||
const loadMessages = async (locale: Locale) => {
|
||||
const load = messageLoaders[`../../locales/${locale}.po`];
|
||||
|
||||
if (!load) throw new Error(`Unknown locale: ${locale}`);
|
||||
|
||||
const { messages } = await load();
|
||||
return messages;
|
||||
};
|
||||
|
||||
export const getLocaleMessages = async (locale: string) => {
|
||||
const resolvedLocale = resolveLocale(locale);
|
||||
let messages: Messages;
|
||||
|
||||
try {
|
||||
messages = await loadMessages(resolvedLocale);
|
||||
return { locale: resolvedLocale, messages };
|
||||
} catch {
|
||||
messages = await loadMessages(defaultLocale);
|
||||
return { locale: defaultLocale, messages };
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLocale = async (locale: string) => {
|
||||
const { locale: resolvedLocale, messages } = await getLocaleMessages(locale);
|
||||
i18n.loadAndActivate({ locale: resolvedLocale, messages });
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { InferRouterInputs, InferRouterOutputs, RouterClient } from "@orpc/server";
|
||||
import { createORPCClient, onError } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { BatchLinkPlugin } from "@orpc/client/plugins";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { getLocale } from "@/libs/locale";
|
||||
|
||||
const getORPCClient = createIsomorphicFn()
|
||||
.server((): RouterClient<typeof router> => {
|
||||
return createRouterClient(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[oRPC server]", error);
|
||||
}),
|
||||
],
|
||||
context: async () => {
|
||||
const locale = await getLocale();
|
||||
const reqHeaders = getRequestHeaders();
|
||||
|
||||
return { locale, reqHeaders };
|
||||
},
|
||||
});
|
||||
})
|
||||
.client((): RouterClient<typeof router> => {
|
||||
const link = new RPCLink({
|
||||
url: `${window.location.origin}/api/rpc`,
|
||||
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
|
||||
plugins: [
|
||||
new BatchLinkPlugin({
|
||||
mode: typeof window === "undefined" ? "buffered" : "streaming",
|
||||
groups: [{ condition: () => true, context: {} }],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("[oRPC client]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return createORPCClient(link);
|
||||
});
|
||||
|
||||
export const client = getORPCClient();
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client);
|
||||
|
||||
export type RouterInput = InferRouterInputs<typeof router>;
|
||||
|
||||
export type RouterOutput = InferRouterOutputs<typeof router>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ManifestOptions } from "vite-plugin-pwa";
|
||||
|
||||
const pwaAppName = "Reactive Resume";
|
||||
const pwaShortDescription = "A free and open-source resume builder.";
|
||||
const pwaThemeColor = "#09090B";
|
||||
const pwaBackgroundColor = "#09090B";
|
||||
|
||||
export const pwaManifest = {
|
||||
name: pwaAppName,
|
||||
short_name: pwaAppName,
|
||||
description: pwaShortDescription,
|
||||
id: "/?source=pwa",
|
||||
start_url: "/?source=pwa",
|
||||
scope: "/",
|
||||
lang: "en",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
theme_color: pwaThemeColor,
|
||||
background_color: pwaBackgroundColor,
|
||||
icons: [
|
||||
{
|
||||
src: "favicon.ico",
|
||||
sizes: "128x128",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
{
|
||||
src: "pwa-64x64.png",
|
||||
sizes: "64x64",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "maskable-icon-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
src: "screenshots/web/1-landing-page.webp",
|
||||
sizes: "1920x1080 any",
|
||||
type: "image/webp",
|
||||
form_factor: "wide",
|
||||
label: "Landing Page",
|
||||
},
|
||||
{
|
||||
src: "screenshots/web/2-resume-dashboard.webp",
|
||||
sizes: "1920x1080 any",
|
||||
type: "image/webp",
|
||||
form_factor: "wide",
|
||||
label: "Resume Dashboard",
|
||||
},
|
||||
{
|
||||
src: "screenshots/web/3-builder-screen.webp",
|
||||
sizes: "1920x1080 any",
|
||||
type: "image/webp",
|
||||
form_factor: "wide",
|
||||
label: "Builder Screen",
|
||||
},
|
||||
{
|
||||
src: "screenshots/web/4-template-gallery.webp",
|
||||
sizes: "1920x1080 any",
|
||||
type: "image/webp",
|
||||
form_factor: "wide",
|
||||
label: "Template Gallery",
|
||||
},
|
||||
{
|
||||
src: "screenshots/mobile/1-landing-page.webp",
|
||||
sizes: "1284x2778 any",
|
||||
type: "image/webp",
|
||||
form_factor: "narrow",
|
||||
label: "Landing Page",
|
||||
},
|
||||
{
|
||||
src: "screenshots/mobile/2-resume-dashboard.webp",
|
||||
sizes: "1284x2778 any",
|
||||
type: "image/webp",
|
||||
form_factor: "narrow",
|
||||
label: "Resume Dashboard",
|
||||
},
|
||||
{
|
||||
src: "screenshots/mobile/3-builder-screen.webp",
|
||||
sizes: "1284x2778 any",
|
||||
type: "image/webp",
|
||||
form_factor: "narrow",
|
||||
label: "Builder Screen",
|
||||
},
|
||||
{
|
||||
src: "screenshots/mobile/4-template-gallery.webp",
|
||||
sizes: "1284x2778 any",
|
||||
type: "image/webp",
|
||||
form_factor: "narrow",
|
||||
label: "Template Gallery",
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
"ai",
|
||||
"builder",
|
||||
"business",
|
||||
"career",
|
||||
"cv",
|
||||
"editor",
|
||||
"free",
|
||||
"generator",
|
||||
"job-search",
|
||||
"multilingual",
|
||||
"open-source",
|
||||
"privacy",
|
||||
"productivity",
|
||||
"resume",
|
||||
"self-hosted",
|
||||
"templates",
|
||||
"utilities",
|
||||
"writing",
|
||||
],
|
||||
} satisfies Partial<ManifestOptions>;
|
||||
|
||||
export const pwaHeadMetaTags = [
|
||||
{ name: "theme-color", content: pwaThemeColor },
|
||||
{ name: "application-name", content: pwaAppName },
|
||||
{ name: "mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-title", content: pwaAppName },
|
||||
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
|
||||
];
|
||||
|
||||
export const pwaServiceWorkerRegistrationScript = `
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
});
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StandardRPCJsonSerializer } from "@orpc/client/standard";
|
||||
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const serializer = new StandardRPCJsonSerializer();
|
||||
|
||||
export const getQueryClient = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
queryKeyHashFn(queryKey) {
|
||||
const [json, meta] = serializer.serialize(queryKey);
|
||||
return JSON.stringify({ json, meta });
|
||||
},
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData(data) {
|
||||
const [json, meta] = serializer.serialize(data);
|
||||
return { json, meta };
|
||||
},
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData(data) {
|
||||
return serializer.deserialize(data.json, data.meta);
|
||||
},
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
onSettled: (_1, _2, _3, _4, _5, context) => {
|
||||
if (context?.meta?.noInvalidate) return;
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
|
||||
/**
|
||||
* Resolves initial values for section-item create dialogs.
|
||||
*
|
||||
* Expected usage:
|
||||
* - Duplicate flow: pass a fully schema-valid `item`; this helper clones it and always generates a fresh `id`.
|
||||
* - Create flow: pass no `item`; this helper uses `createItem` and still guarantees a fresh `id`.
|
||||
*
|
||||
* This helper intentionally does not deep-merge partial items. Callers should provide either a complete item
|
||||
* (duplicate) or no item (create) to keep the seam explicit and predictable.
|
||||
*/
|
||||
export function makeSectionItem<T extends { id: string }>(defaultItem: T, item?: T): T {
|
||||
if (item) return { ...item, id: generateId() };
|
||||
return { ...defaultItem, id: generateId() };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type {
|
||||
CustomSection,
|
||||
CustomSectionType,
|
||||
ResumeData,
|
||||
SectionItem,
|
||||
SectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { WritableDraft } from "immer";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { getSectionTitle as getDefaultSectionTitle } from "./section";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** Page with its compatible sections for the move menu */
|
||||
type MoveTargetPage = {
|
||||
pageIndex: number;
|
||||
sections: MoveTargetSection[];
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks if a section ID belongs to a standard section.
|
||||
* 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",
|
||||
];
|
||||
|
||||
return standardSections.includes(sectionId as SectionType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Gets the title of a section.
|
||||
* For standard sections, returns the localized default title.
|
||||
* For custom sections, returns the user-defined title.
|
||||
*
|
||||
* @param resumeData - The resume data object
|
||||
* @param type - The section type
|
||||
* @param customSectionId - The custom section ID (if applicable)
|
||||
* @returns The section title
|
||||
*/
|
||||
export function getSourceSectionTitle(
|
||||
resumeData: ResumeData,
|
||||
type: CustomSectionType,
|
||||
customSectionId?: string,
|
||||
): string {
|
||||
if (customSectionId) {
|
||||
const customSection = resumeData.customSections.find((s) => s.id === customSectionId);
|
||||
return customSection?.title ?? getDefaultSectionTitle(type);
|
||||
}
|
||||
|
||||
return getDefaultSectionTitle(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all compatible sections an item can be moved to.
|
||||
* A section is compatible if it has the same type as the source section.
|
||||
*
|
||||
* @param resumeData - The resume data object
|
||||
* @param sourceType - The type of the source section
|
||||
* @param sourceSectionId - The ID of the source section (custom section ID or undefined for standard)
|
||||
* @returns Array of pages with their compatible sections
|
||||
*/
|
||||
export function getCompatibleMoveTargets(
|
||||
resumeData: ResumeData,
|
||||
sourceType: CustomSectionType,
|
||||
sourceSectionId: string | undefined,
|
||||
): 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 (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 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 });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from its source section (standard or custom).
|
||||
*
|
||||
* @param draft - The immer draft of resume data
|
||||
* @param itemId - The ID of the item to remove
|
||||
* @param type - The section type
|
||||
* @param customSectionId - The custom section ID (if applicable)
|
||||
* @returns The removed item, or null if not found
|
||||
*/
|
||||
export function removeItemFromSource(
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 [removed] = section.items.splice(index, 1);
|
||||
return removed as SectionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item to a target section.
|
||||
*
|
||||
* @param draft - The immer draft of resume data
|
||||
* @param item - The item to add
|
||||
* @param targetSectionId - The target section ID
|
||||
* @param type - The section type
|
||||
*/
|
||||
export function addItemToSection(
|
||||
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;
|
||||
}
|
||||
|
||||
// Otherwise, it's a custom section
|
||||
const customSection = draft.customSections.find((s) => s.id === targetSectionId);
|
||||
if (customSection) {
|
||||
customSection.items.push(item as never);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new custom section with the given item and adds it to the specified page.
|
||||
*
|
||||
* @param draft - The immer draft of resume data
|
||||
* @param item - The item to add to the new section
|
||||
* @param type - The section type for the new custom section
|
||||
* @param sectionTitle - The title for the new custom section
|
||||
* @param targetPageIndex - The page index to add the section to
|
||||
* @returns The ID of the newly created custom section
|
||||
*/
|
||||
export function createCustomSectionWithItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
item: SectionItem,
|
||||
type: CustomSectionType,
|
||||
sectionTitle: string,
|
||||
targetPageIndex: number,
|
||||
): string {
|
||||
const newSectionId = generateId();
|
||||
|
||||
// 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>);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new page with a custom section containing the given item.
|
||||
*
|
||||
* @param draft - The immer draft of resume data
|
||||
* @param item - The item to add to the new section
|
||||
* @param type - The section type for the new custom section
|
||||
* @param sectionTitle - The title for the new custom section
|
||||
*/
|
||||
export function createPageWithSection(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
item: SectionItem,
|
||||
type: CustomSectionType,
|
||||
sectionTitle: string,
|
||||
): void {
|
||||
const newSectionId = generateId();
|
||||
|
||||
// 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>);
|
||||
|
||||
// Create the new page with the section in the main column
|
||||
draft.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: [newSectionId],
|
||||
sidebar: [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { ResumeDocument } from "@reactive-resume/pdf/document";
|
||||
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "./section-title-locale";
|
||||
|
||||
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
||||
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!data || !sectionTitleResolver) return null;
|
||||
|
||||
return (
|
||||
<ResumeDocument
|
||||
data={data}
|
||||
template={template ?? data.metadata.template}
|
||||
resolveSectionTitle={sectionTitleResolver}
|
||||
/>
|
||||
);
|
||||
}, [data, template, sectionTitleResolver]);
|
||||
};
|
||||
|
||||
const createLocalizedResumeDocument = async (data: ResumeData, template?: Template) => {
|
||||
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
||||
|
||||
return (
|
||||
<ResumeDocument
|
||||
data={data}
|
||||
template={template ?? data.metadata.template}
|
||||
resolveSectionTitle={sectionTitleResolver}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const createResumePdfBlob = async (data: ResumeData, template?: Template) => {
|
||||
return pdf(await createLocalizedResumeDocument(data, template)).toBlob();
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { WritableDraft } from "immer";
|
||||
|
||||
/**
|
||||
* Pushes a new item into a section's items array.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function createSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (section) section.items.push(formData as never);
|
||||
} else {
|
||||
(draft.sections[sectionKey].items as unknown[]).push(formData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and replaces an existing item in a section's items array by id.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function updateSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: { id: string } & Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData as never;
|
||||
} else {
|
||||
const items = draft.sections[sectionKey].items as Array<{ id: string }>;
|
||||
const index = items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) (items[index] as unknown as Record<string, unknown>) = formData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SectionTitleResolver } from "@reactive-resume/pdf/section-title";
|
||||
import { setupI18n } from "@lingui/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getLocaleMessages, resolveLocale } from "@/libs/locale";
|
||||
import { createSectionTitleResolver } from "./section-title";
|
||||
|
||||
const resolverCache = new Map<string, Promise<SectionTitleResolver>>();
|
||||
|
||||
export const createSectionTitleResolverForLocale = async (localeParam: string) => {
|
||||
const requestedLocale = resolveLocale(localeParam);
|
||||
const cachedResolver = resolverCache.get(requestedLocale);
|
||||
|
||||
if (cachedResolver) return cachedResolver;
|
||||
|
||||
const resolver = getLocaleMessages(requestedLocale).then(({ locale, messages }) => {
|
||||
const i18n = setupI18n({ locale });
|
||||
i18n.loadAndActivate({ locale, messages });
|
||||
|
||||
return createSectionTitleResolver(i18n);
|
||||
});
|
||||
|
||||
resolverCache.set(requestedLocale, resolver);
|
||||
|
||||
return resolver;
|
||||
};
|
||||
|
||||
export const useSectionTitleResolver = (locale?: string) => {
|
||||
const [resolver, setResolver] = useState<SectionTitleResolver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locale) {
|
||||
setResolver(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
setResolver(null);
|
||||
void createSectionTitleResolverForLocale(locale).then((nextResolver) => {
|
||||
if (!cancelled) setResolver(() => nextResolver);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
return resolver;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MessageDescriptor } from "@lingui/core";
|
||||
import type { SectionTitleResolver } from "@reactive-resume/pdf/section-title";
|
||||
import type { CustomSectionType, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
type SectionTranslator = {
|
||||
_: (descriptor: MessageDescriptor) => string;
|
||||
};
|
||||
|
||||
const sectionTitleMessages = {
|
||||
summary: msg`Summary`,
|
||||
profiles: msg`Profiles`,
|
||||
experience: msg`Experience`,
|
||||
education: msg`Education`,
|
||||
projects: msg`Projects`,
|
||||
skills: msg`Skills`,
|
||||
languages: msg`Languages`,
|
||||
interests: msg`Interests`,
|
||||
awards: msg`Awards`,
|
||||
certifications: msg`Certifications`,
|
||||
publications: msg`Publications`,
|
||||
volunteer: msg`Volunteer`,
|
||||
references: msg`References`,
|
||||
"cover-letter": msg`Cover Letter`,
|
||||
} satisfies Record<"summary" | SectionType | CustomSectionType, MessageDescriptor>;
|
||||
|
||||
export const createSectionTitleResolver = (translator: SectionTranslator = i18n): SectionTitleResolver => {
|
||||
return ({ sectionId, sectionKind, customSectionType, defaultEnglishTitle }) => {
|
||||
const sectionType = sectionKind === "custom" ? customSectionType : sectionId;
|
||||
const message = sectionTitleMessages[sectionType as keyof typeof sectionTitleMessages];
|
||||
|
||||
if (!message) return defaultEnglishTitle ?? sectionId;
|
||||
|
||||
return translator._(message) || defaultEnglishTitle || sectionId;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { IconProps } from "@phosphor-icons/react";
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArticleIcon,
|
||||
BooksIcon,
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
CertificateIcon,
|
||||
ChartLineIcon,
|
||||
CodeSimpleIcon,
|
||||
CompassToolIcon,
|
||||
DiamondsFourIcon,
|
||||
DownloadIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
FootballIcon,
|
||||
GraduationCapIcon,
|
||||
HandHeartIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
LayoutIcon,
|
||||
MessengerLogoIcon,
|
||||
NotepadIcon,
|
||||
PaletteIcon,
|
||||
PhoneIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShareFatIcon,
|
||||
StarIcon,
|
||||
TextTIcon,
|
||||
TranslateIcon,
|
||||
TrophyIcon,
|
||||
UserIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { match } from "ts-pattern";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType | "custom";
|
||||
|
||||
// CustomSectionType values that are not in SectionType (used in custom sections only)
|
||||
type CustomOnlyType = "cover-letter";
|
||||
|
||||
export type RightSidebarSection =
|
||||
| "template"
|
||||
| "layout"
|
||||
| "typography"
|
||||
| "design"
|
||||
| "page"
|
||||
| "notes"
|
||||
| "sharing"
|
||||
| "statistics"
|
||||
| "analysis"
|
||||
| "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",
|
||||
] as const;
|
||||
|
||||
export const rightSidebarSections: RightSidebarSection[] = [
|
||||
"template",
|
||||
"layout",
|
||||
"typography",
|
||||
"design",
|
||||
"page",
|
||||
"notes",
|
||||
"sharing",
|
||||
"statistics",
|
||||
"analysis",
|
||||
"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`)
|
||||
|
||||
// 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("notes", () => t`Notes`)
|
||||
.with("sharing", () => t`Sharing`)
|
||||
.with("statistics", () => t`Statistics`)
|
||||
.with("analysis", () => t`Resume Analysis`)
|
||||
.with("export", () => t`Export`)
|
||||
.with("information", () => t`Information`)
|
||||
|
||||
.exhaustive()
|
||||
);
|
||||
};
|
||||
|
||||
export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: IconProps): React.ReactNode => {
|
||||
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} />)
|
||||
|
||||
// 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("notes", () => <NotepadIcon {...iconProps} />)
|
||||
.with("sharing", () => <ShareFatIcon {...iconProps} />)
|
||||
.with("statistics", () => <ChartLineIcon {...iconProps} />)
|
||||
.with("analysis", () => <BrainIcon {...iconProps} />)
|
||||
.with("export", () => <DownloadIcon {...iconProps} />)
|
||||
.with("information", () => <InfoIcon {...iconProps} />)
|
||||
|
||||
.exhaustive()
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type * as React from "react";
|
||||
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
|
||||
type FieldFrameProps = {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
formItemClassName?: string;
|
||||
};
|
||||
|
||||
type TextFieldProps = FieldFrameProps &
|
||||
Omit<React.ComponentProps<typeof Input>, "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value">;
|
||||
|
||||
type InputGroupTextFieldProps = FieldFrameProps &
|
||||
Omit<
|
||||
React.ComponentProps<typeof InputGroupInput>,
|
||||
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value"
|
||||
>;
|
||||
|
||||
type NumberFieldProps = FieldFrameProps &
|
||||
Omit<
|
||||
React.ComponentProps<typeof Input>,
|
||||
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "type" | "value"
|
||||
>;
|
||||
|
||||
const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
|
||||
|
||||
function TextField({ label, description, formItemClassName, ...props }: TextFieldProps) {
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
{...props}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextField({ label, description, formItemClassName, ...props }: InputGroupTextFieldProps) {
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
{...props}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, description, formItemClassName, ...props }: NumberFieldProps) {
|
||||
const field = useFieldContext<number>();
|
||||
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
|
||||
const value = Number.isFinite(field.state.value) ? field.state.value : "";
|
||||
|
||||
return (
|
||||
<FormItem hasError={hasError} className={formItemClassName}>
|
||||
{label ? <FormLabel>{label}</FormLabel> : null}
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
{...props}
|
||||
type="number"
|
||||
name={field.name}
|
||||
value={value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.valueAsNumber)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export const { useAppForm, withForm } = createFormHook({
|
||||
fieldComponents: { InputGroupTextField, NumberField, TextField },
|
||||
fieldContext,
|
||||
formComponents: {},
|
||||
formContext,
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MessageDescriptor } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { createIsomorphicFn, createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import Cookies from "js-cookie";
|
||||
import z from "zod";
|
||||
|
||||
const themeSchema = z.union([z.literal("light"), z.literal("dark")]);
|
||||
|
||||
export type Theme = z.infer<typeof themeSchema>;
|
||||
|
||||
const storageKey = "theme";
|
||||
const defaultTheme: Theme = "dark";
|
||||
|
||||
export const themeMap = {
|
||||
light: msg`Light`,
|
||||
dark: msg`Dark`,
|
||||
} satisfies Record<Theme, MessageDescriptor>;
|
||||
|
||||
export function isTheme(theme: string): theme is Theme {
|
||||
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;
|
||||
});
|
||||
|
||||
export const setThemeServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(themeSchema)
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(storageKey, data);
|
||||
});
|
||||
Reference in New Issue
Block a user