* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
+40
View File
@@ -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();
+25
View File
@@ -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;
});
+36
View File
@@ -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.",
});
}
+200
View File
@@ -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 });
};
+55
View File
@@ -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>;
+144
View File
@@ -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: "/" });
});
}
`;
+38
View File
@@ -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() };
}
+280
View File
@@ -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: [],
});
}
+38
View File
@@ -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;
};
+37
View File
@@ -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;
};
};
+172
View File
@@ -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()
);
};
+111
View File
@@ -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,
});
+40
View File
@@ -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);
});