mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { CreateApiKeyDialog } from "./create";
|
||||
|
||||
export const apiKeyDialogRendererRegistry = defineDialogRendererRegistry("api-key", [
|
||||
defineDialogRenderer("api-key.create", () => <CreateApiKeyDialog />),
|
||||
]);
|
||||
@@ -0,0 +1,3 @@
|
||||
import z from "zod";
|
||||
|
||||
export const apiKeyDialogSchemas = [z.object({ type: z.literal("api-key.create"), data: z.undefined() })] as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { ChangePasswordDialog } from "./change-password";
|
||||
import { DisableTwoFactorDialog } from "./disable-two-factor";
|
||||
import { EnableTwoFactorDialog } from "./enable-two-factor";
|
||||
|
||||
export const authDialogRendererRegistry = defineDialogRendererRegistry("auth", [
|
||||
defineDialogRenderer("auth.change-password", () => <ChangePasswordDialog />),
|
||||
defineDialogRenderer("auth.two-factor.enable", () => <EnableTwoFactorDialog />),
|
||||
defineDialogRenderer("auth.two-factor.disable", () => <DisableTwoFactorDialog />),
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const authDialogSchemas = [
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
] as const;
|
||||
@@ -1,73 +1,11 @@
|
||||
import { match } from "ts-pattern";
|
||||
import { Dialog } from "@reactive-resume/ui/components/dialog";
|
||||
import { CreateApiKeyDialog } from "./api-key/create";
|
||||
import { ChangePasswordDialog } from "./auth/change-password";
|
||||
import { DisableTwoFactorDialog } from "./auth/disable-two-factor";
|
||||
import { EnableTwoFactorDialog } from "./auth/enable-two-factor";
|
||||
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from "./resume";
|
||||
import { ImportResumeDialog } from "./resume/import";
|
||||
import { CreateAwardDialog, UpdateAwardDialog } from "./resume/sections/award";
|
||||
import { CreateCertificationDialog, UpdateCertificationDialog } from "./resume/sections/certification";
|
||||
import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./resume/sections/cover-letter";
|
||||
import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./resume/sections/custom";
|
||||
import { CreateEducationDialog, UpdateEducationDialog } from "./resume/sections/education";
|
||||
import { CreateExperienceDialog, UpdateExperienceDialog } from "./resume/sections/experience";
|
||||
import { CreateInterestDialog, UpdateInterestDialog } from "./resume/sections/interest";
|
||||
import { CreateLanguageDialog, UpdateLanguageDialog } from "./resume/sections/language";
|
||||
import { CreateProfileDialog, UpdateProfileDialog } from "./resume/sections/profile";
|
||||
import { CreateProjectDialog, UpdateProjectDialog } from "./resume/sections/project";
|
||||
import { CreatePublicationDialog, UpdatePublicationDialog } from "./resume/sections/publication";
|
||||
import { CreateReferenceDialog, UpdateReferenceDialog } from "./resume/sections/reference";
|
||||
import { CreateSkillDialog, UpdateSkillDialog } from "./resume/sections/skill";
|
||||
import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./resume/sections/summary-item";
|
||||
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./resume/sections/volunteer";
|
||||
import { TemplateGalleryDialog } from "./resume/template/gallery";
|
||||
import { renderDialog } from "./renderers";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
export function DialogManager() {
|
||||
const { open, activeDialog, onOpenChange } = useDialogStore();
|
||||
|
||||
const DialogContent = match(activeDialog)
|
||||
.with({ type: "auth.change-password" }, () => <ChangePasswordDialog />)
|
||||
.with({ type: "auth.two-factor.enable" }, () => <EnableTwoFactorDialog />)
|
||||
.with({ type: "auth.two-factor.disable" }, () => <DisableTwoFactorDialog />)
|
||||
.with({ type: "api-key.create" }, () => <CreateApiKeyDialog />)
|
||||
.with({ type: "resume.create" }, () => <CreateResumeDialog />)
|
||||
.with({ type: "resume.update" }, ({ data }) => <UpdateResumeDialog data={data} />)
|
||||
.with({ type: "resume.duplicate" }, ({ data }) => <DuplicateResumeDialog data={data} />)
|
||||
.with({ type: "resume.import" }, () => <ImportResumeDialog />)
|
||||
.with({ type: "resume.template.gallery" }, () => <TemplateGalleryDialog />)
|
||||
.with({ type: "resume.sections.profiles.create" }, ({ data }) => <CreateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.profiles.update" }, ({ data }) => <UpdateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.create" }, ({ data }) => <CreateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.update" }, ({ data }) => <UpdateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.create" }, ({ data }) => <CreateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.update" }, ({ data }) => <UpdateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.create" }, ({ data }) => <CreateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.update" }, ({ data }) => <UpdateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.create" }, ({ data }) => <CreateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.update" }, ({ data }) => <UpdateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.create" }, ({ data }) => <CreateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.update" }, ({ data }) => <UpdateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.create" }, ({ data }) => <CreateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.update" }, ({ data }) => <UpdateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.create" }, ({ data }) => <CreatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.update" }, ({ data }) => <UpdatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.create" }, ({ data }) => <CreateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.update" }, ({ data }) => <UpdateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.create" }, ({ data }) => <CreateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.update" }, ({ data }) => <UpdateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.create" }, ({ data }) => <CreateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.update" }, ({ data }) => <UpdateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.create" }, ({ data }) => <CreateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.update" }, ({ data }) => <UpdateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.create" }, ({ data }) => <CreateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.update" }, ({ data }) => <UpdateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.create" }, ({ data }) => <CreateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.update" }, ({ data }) => <UpdateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.create" }, ({ data }) => <CreateCustomSectionDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.update" }, ({ data }) => <UpdateCustomSectionDialog data={data} />)
|
||||
.otherwise(() => null);
|
||||
const DialogContent = renderDialog(activeDialog);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DialogSchema, DialogType } from "./schemas";
|
||||
|
||||
export interface DialogRendererEntry<T extends DialogType = DialogType> {
|
||||
type: T;
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
|
||||
}
|
||||
|
||||
export type AnyDialogRendererEntry = {
|
||||
[T in DialogType]: DialogRendererEntry<T>;
|
||||
}[DialogType];
|
||||
|
||||
export interface DialogRendererRegistry {
|
||||
domain: string;
|
||||
renderers: readonly AnyDialogRendererEntry[];
|
||||
}
|
||||
|
||||
export const defineDialogRenderer = <T extends DialogType>(
|
||||
type: T,
|
||||
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode,
|
||||
): DialogRendererEntry<T> => ({ type, render });
|
||||
|
||||
export const defineDialogRendererRegistry = (
|
||||
domain: string,
|
||||
renderers: readonly AnyDialogRendererEntry[],
|
||||
): DialogRendererRegistry => ({ domain, renderers });
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DialogSchema } from "./schemas";
|
||||
import { apiKeyDialogRendererRegistry } from "./api-key/registry";
|
||||
import { authDialogRendererRegistry } from "./auth/registry";
|
||||
import { resumeDialogRendererRegistry } from "./resume/registry";
|
||||
|
||||
const dialogRendererRegistries = [
|
||||
authDialogRendererRegistry,
|
||||
apiKeyDialogRendererRegistry,
|
||||
resumeDialogRendererRegistry,
|
||||
] as const;
|
||||
|
||||
export const renderDialog = (dialog: DialogSchema | null) => {
|
||||
if (!dialog) return null;
|
||||
|
||||
for (const registry of dialogRendererRegistries) {
|
||||
const renderer = registry.renderers.find((entry) => entry.type === dialog.type);
|
||||
if (renderer) return renderer.render(dialog as never);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { usePatchResume } from "@/components/resume/builder-resume-draft";
|
||||
import { usePatchResume } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
|
||||
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
|
||||
import { ImportResumeDialog } from "./import";
|
||||
import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award";
|
||||
import { CreateCertificationDialog, UpdateCertificationDialog } from "./sections/certification";
|
||||
import { CreateCoverLetterDialog, UpdateCoverLetterDialog } from "./sections/cover-letter";
|
||||
import { CreateCustomSectionDialog, UpdateCustomSectionDialog } from "./sections/custom";
|
||||
import { CreateEducationDialog, UpdateEducationDialog } from "./sections/education";
|
||||
import { CreateExperienceDialog, UpdateExperienceDialog } from "./sections/experience";
|
||||
import { CreateInterestDialog, UpdateInterestDialog } from "./sections/interest";
|
||||
import { CreateLanguageDialog, UpdateLanguageDialog } from "./sections/language";
|
||||
import { CreateProfileDialog, UpdateProfileDialog } from "./sections/profile";
|
||||
import { CreateProjectDialog, UpdateProjectDialog } from "./sections/project";
|
||||
import { CreatePublicationDialog, UpdatePublicationDialog } from "./sections/publication";
|
||||
import { CreateReferenceDialog, UpdateReferenceDialog } from "./sections/reference";
|
||||
import { CreateSkillDialog, UpdateSkillDialog } from "./sections/skill";
|
||||
import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/summary-item";
|
||||
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer";
|
||||
import { TemplateGalleryDialog } from "./template/gallery";
|
||||
|
||||
export const resumeDialogRendererRegistry = defineDialogRendererRegistry("resume", [
|
||||
defineDialogRenderer("resume.create", () => <CreateResumeDialog />),
|
||||
defineDialogRenderer("resume.update", ({ data }) => <UpdateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.duplicate", ({ data }) => <DuplicateResumeDialog data={data} />),
|
||||
defineDialogRenderer("resume.import", () => <ImportResumeDialog />),
|
||||
defineDialogRenderer("resume.template.gallery", () => <TemplateGalleryDialog />),
|
||||
defineDialogRenderer("resume.sections.profiles.create", ({ data }) => <CreateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.profiles.update", ({ data }) => <UpdateProfileDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.create", ({ data }) => <CreateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.experience.update", ({ data }) => <UpdateExperienceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.create", ({ data }) => <CreateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.education.update", ({ data }) => <UpdateEducationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.create", ({ data }) => <CreateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.skills.update", ({ data }) => <UpdateSkillDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.create", ({ data }) => <CreateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.projects.update", ({ data }) => <UpdateProjectDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.certifications.create", ({ data }) => (
|
||||
<CreateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.certifications.update", ({ data }) => (
|
||||
<UpdateCertificationDialog data={data} />
|
||||
)),
|
||||
defineDialogRenderer("resume.sections.languages.create", ({ data }) => <CreateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.languages.update", ({ data }) => <UpdateLanguageDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.create", ({ data }) => <CreatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.publications.update", ({ data }) => <UpdatePublicationDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.create", ({ data }) => <CreateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.awards.update", ({ data }) => <UpdateAwardDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.create", ({ data }) => <CreateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.interests.update", ({ data }) => <UpdateInterestDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.create", ({ data }) => <CreateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.volunteer.update", ({ data }) => <UpdateVolunteerDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.create", ({ data }) => <CreateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.references.update", ({ data }) => <UpdateReferenceDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.create", ({ data }) => <CreateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.summary.update", ({ data }) => <UpdateSummaryItemDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.create", ({ data }) => <CreateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.cover-letter.update", ({ data }) => <UpdateCoverLetterDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.create", ({ data }) => <CreateCustomSectionDialog data={data} />),
|
||||
defineDialogRenderer("resume.sections.custom.update", ({ data }) => <UpdateCustomSectionDialog data={data} />),
|
||||
]);
|
||||
@@ -0,0 +1,152 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export const resumeDialogSchemas = [
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
] as const;
|
||||
@@ -17,8 +17,8 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -20,8 +20,8 @@ import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -19,8 +19,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -26,8 +26,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -21,8 +21,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { useAppForm, withForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -16,8 +16,8 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { makeSectionItem } from "@/libs/resume/make-section-item";
|
||||
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDialogStore } from "@/dialogs/store";
|
||||
|
||||
const updateResumeData = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { metadata: { template: "ditto" } },
|
||||
}),
|
||||
|
||||
@@ -10,8 +10,8 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { templates } from "./data";
|
||||
|
||||
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import z from "zod";
|
||||
import { apiKeyDialogSchemas } from "./api-key/schema";
|
||||
import { authDialogSchemas } from "./auth/schema";
|
||||
import { resumeDialogSchemas } from "./resume/schema";
|
||||
|
||||
export const dialogSchemaRegistries = [
|
||||
{ domain: "auth", schemas: authDialogSchemas },
|
||||
{ domain: "api-key", schemas: apiKeyDialogSchemas },
|
||||
{ domain: "resume", schemas: resumeDialogSchemas },
|
||||
] as const;
|
||||
|
||||
const dialogSchemaEntries = [...authDialogSchemas, ...apiKeyDialogSchemas, ...resumeDialogSchemas] as const;
|
||||
|
||||
export const dialogTypeSchema = z.discriminatedUnion("type", dialogSchemaEntries);
|
||||
|
||||
export type DialogSchema = z.infer<typeof dialogTypeSchema>;
|
||||
export type DialogType = DialogSchema["type"];
|
||||
|
||||
export type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
|
||||
|
||||
type DialogPropsData<T extends DialogType> =
|
||||
DialogData<T> extends undefined ? Record<string, never> : { data: DialogData<T> };
|
||||
|
||||
export type DialogProps<T extends DialogType> = DialogPropsData<T>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dialogSchemaRegistries, dialogTypeSchema } from "./schemas";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
describe("useDialogStore", () => {
|
||||
@@ -13,6 +14,13 @@ describe("useDialogStore", () => {
|
||||
});
|
||||
|
||||
describe("openDialog", () => {
|
||||
it("uses schema entries from domain registries", () => {
|
||||
expect(dialogSchemaRegistries.map((registry) => registry.domain)).toEqual(["auth", "api-key", "resume"]);
|
||||
expect(dialogTypeSchema.options).toHaveLength(
|
||||
dialogSchemaRegistries.reduce((total, registry) => total + registry.schemas.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a dialog and sets activeDialog", () => {
|
||||
useDialogStore.getState().openDialog("api-key.create", undefined);
|
||||
|
||||
|
||||
@@ -1,170 +1,7 @@
|
||||
import z from "zod";
|
||||
import type { DialogData, DialogProps, DialogSchema, DialogType } from "./schemas";
|
||||
import { create } from "zustand/react";
|
||||
import {
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
|
||||
const dialogTypeSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("api-key.create"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
]);
|
||||
|
||||
type DialogSchema = z.infer<typeof dialogTypeSchema>;
|
||||
type DialogType = DialogSchema["type"];
|
||||
|
||||
type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
|
||||
|
||||
type DialogPropsData<T extends DialogType> =
|
||||
DialogData<T> extends undefined ? Record<string, never> : { data: DialogData<T> };
|
||||
|
||||
export type DialogProps<T extends DialogType> = DialogPropsData<T>;
|
||||
export type { DialogProps };
|
||||
|
||||
interface DialogStoreState {
|
||||
open: boolean;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center space-y-6 px-4 xs:px-0">
|
||||
<BrandIcon className="mb-4 size-20 self-center" />
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Sending password reset email...`);
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: value.email,
|
||||
redirectTo: "/auth/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostForgotPasswordScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Remember your password?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostForgotPasswordScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to reset your password.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
|
||||
const formSchema = z.object({
|
||||
identifier: z.string().trim().toLowerCase(),
|
||||
password: z.string().trim().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
disableEmailAuth: boolean;
|
||||
disableSignups: boolean;
|
||||
};
|
||||
|
||||
export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
try {
|
||||
const isEmail = value.identifier.includes("@");
|
||||
|
||||
const result = isEmail
|
||||
? await authClient.signIn.email({ email: value.identifier, password: value.password })
|
||||
: await authClient.signIn.username({ username: value.identifier, password: value.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTwoFactor =
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
"twoFactorRedirect" in result.data &&
|
||||
result.data.twoFactorRedirect;
|
||||
|
||||
if (requiresTwoFactor) {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: "/auth/verify-2fa", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
} catch {
|
||||
toast.error(t`Failed to sign in. Please try again.`, { id: toastId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!disableSignups && (
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="identifier">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
<FormDescription>
|
||||
<Trans>You can also use your username to login.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-login current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().toLowerCase(),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
disableEmailAuth: boolean;
|
||||
};
|
||||
|
||||
export function RegisterPage({ disableEmailAuth }: Props) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: "", username: "", email: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing up...`);
|
||||
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
username: value.username,
|
||||
displayUsername: value.username,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
|
||||
if (submitted) return <PostSignupScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="username">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-register new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth requestSignUp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSignupScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to verify your account.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
<Trans>This step is optional, but recommended.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Verifying your email is required when resetting your password.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function ResetPasswordPage({ token }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Resetting your password...`);
|
||||
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: value.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been reset successfully. You can now sign in with your new password.`, {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
void navigate({ to: "/auth/login" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Reset your password</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Please enter a new password for your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function ResumePasswordPage({ redirectPath }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
|
||||
|
||||
const [username, slug] = useMemo(() => {
|
||||
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
|
||||
if (!username || !slug) throw navigate({ to: "/" });
|
||||
return [username, slug];
|
||||
}, [redirectPath, navigate]);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
const toastId = toast.loading(t`Verifying password...`);
|
||||
|
||||
verifyPassword(
|
||||
{ username, slug, password: value.password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: redirectPath, replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "INVALID_PASSWORD") {
|
||||
toast.dismiss(toastId);
|
||||
formApi.setFieldMeta("password", (meta) => ({
|
||||
...meta,
|
||||
isTouched: true,
|
||||
errors: [{ message: t`The password you entered is incorrect` }],
|
||||
errorMap: {
|
||||
...meta.errorMap,
|
||||
onSubmit: { message: t`The password you entered is incorrect` },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>The resume you are trying to access is password protected</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground leading-relaxed">
|
||||
<Trans>Please enter the password shared with you by the owner of the resume to continue.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().trim(),
|
||||
});
|
||||
|
||||
export function VerifyTwoFactorBackupPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying backup code...`);
|
||||
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Verify with a Backup Code</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter one of your saved backup codes to access your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
maxLength={10}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
export function VerifyTwoFactorPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: value.code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter the verification code from your authenticator app</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="number"
|
||||
maxLength={6}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { I18nProvider } from "@lingui/react";
|
||||
import { Command } from "@reactive-resume/ui/components/command";
|
||||
import { useCommandPaletteStore } from "../../store";
|
||||
|
||||
vi.mock("@/components/theme/provider", () => ({
|
||||
vi.mock("@/features/theme/provider", () => ({
|
||||
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CommandItem } from "@reactive-resume/ui/components/command";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/libs/locale";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function LanguageCommandPage() {
|
||||
@@ -9,7 +9,8 @@ export function LanguageCommandPage() {
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { I18nProvider } from "@lingui/react";
|
||||
import { Command } from "@reactive-resume/ui/components/command";
|
||||
import { useCommandPaletteStore } from "../../store";
|
||||
|
||||
vi.mock("@/components/theme/provider", () => ({
|
||||
vi.mock("@/features/theme/provider", () => ({
|
||||
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
|
||||
import { CommandItem } from "@reactive-resume/ui/components/command";
|
||||
import { useTheme } from "@/components/theme/provider";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
import { useCommandPaletteStore } from "../../store";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@ import type { SingleComboboxProps } from "@/components/ui/combobox";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/libs/locale";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
|
||||
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
@@ -20,7 +20,8 @@ export function LocaleCombobox(props: Props) {
|
||||
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { applyResumePatches, createResumePatches } from "@reactive-resume/utils/resume/patch";
|
||||
import { applyResumePatches, createResumePatches } from "@reactive-resume/resume/patch";
|
||||
import { orpc, streamClient } from "@/libs/orpc/client";
|
||||
|
||||
export type Resume = {
|
||||
+8
-14
@@ -1,9 +1,9 @@
|
||||
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 { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
|
||||
import { ResumeDocument } from "@reactive-resume/pdf/document";
|
||||
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "./section-title-locale";
|
||||
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "@/libs/resume/section-title-locale";
|
||||
|
||||
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
||||
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
||||
@@ -21,18 +21,12 @@ export const useLocalizedResumeDocument = (data?: ResumeData, template?: Templat
|
||||
}, [data, template, sectionTitleResolver]);
|
||||
};
|
||||
|
||||
export const createLocalizedResumeDocument = async (data: ResumeData, template?: Template) => {
|
||||
export const createResumePdfBlob = 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();
|
||||
return createPdfBlob({
|
||||
data,
|
||||
template,
|
||||
resolveSectionTitle: sectionTitleResolver,
|
||||
});
|
||||
};
|
||||
+8
-3
@@ -1,12 +1,17 @@
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import type { ReactNode } from "react";
|
||||
import type { PreviewPageSize } from "./preview.shared";
|
||||
import { AnnotationMode, GlobalWorkerOptions, getDocument, RenderingCancelledException } from "pdfjs-dist";
|
||||
import {
|
||||
AnnotationMode,
|
||||
GlobalWorkerOptions,
|
||||
getDocument,
|
||||
RenderingCancelledException,
|
||||
} from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
type PdfCanvasDocumentProps = {
|
||||
children: (document: PDFDocumentProxy) => ReactNode;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PreviewPageSize } from "./preview.shared";
|
||||
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
|
||||
|
||||
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to create resume thumbnail image."));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
}, "image/png");
|
||||
});
|
||||
};
|
||||
|
||||
export const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
|
||||
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
|
||||
|
||||
try {
|
||||
pdfDocument = await loadingTask.promise;
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
try {
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width };
|
||||
const renderSize = getResumeThumbnailRenderSize(
|
||||
pageSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvasContext = canvas.getContext("2d");
|
||||
|
||||
if (!canvasContext) throw new Error("Failed to create resume thumbnail canvas context.");
|
||||
|
||||
canvas.height = renderSize.height;
|
||||
canvas.width = renderSize.width;
|
||||
|
||||
const viewport = page.getViewport({ scale: renderSize.scale });
|
||||
const renderTask = page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
annotationMode: AnnotationMode.DISABLE,
|
||||
background: "white",
|
||||
});
|
||||
|
||||
await renderTask.promise;
|
||||
|
||||
const image = await canvasToBlob(canvas);
|
||||
return URL.createObjectURL(image);
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
} finally {
|
||||
if (pdfDocument) {
|
||||
void pdfDocument.destroy();
|
||||
} else {
|
||||
void loadingTask.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pdfjsMock = vi.hoisted(() => {
|
||||
const page = {
|
||||
cleanup: vi.fn(),
|
||||
getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve() })),
|
||||
};
|
||||
|
||||
const pdfDocument = {
|
||||
destroy: vi.fn(),
|
||||
getPage: vi.fn(async () => page),
|
||||
numPages: 1,
|
||||
};
|
||||
|
||||
const loadingTask = {
|
||||
destroy: vi.fn(),
|
||||
promise: Promise.resolve(pdfDocument),
|
||||
};
|
||||
|
||||
return {
|
||||
createObjectURL: vi.fn(() => "blob:thumbnail"),
|
||||
getDocument: vi.fn(() => loadingTask),
|
||||
legacyModule: {
|
||||
AnnotationMode: { DISABLE: 0 },
|
||||
getDocument: vi.fn(() => loadingTask),
|
||||
GlobalWorkerOptions: {} as { workerSrc?: string },
|
||||
RenderingCancelledException: class RenderingCancelledException extends Error {},
|
||||
},
|
||||
loadingTask,
|
||||
page,
|
||||
pdfDocument,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("pdfjs-dist", () => {
|
||||
throw new Error("Modern pdfjs-dist runtime should not be imported by browser preview code.");
|
||||
});
|
||||
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => pdfjsMock.legacyModule);
|
||||
|
||||
describe("PDF.js browser entrypoints", () => {
|
||||
beforeEach(() => {
|
||||
pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc = undefined;
|
||||
pdfjsMock.legacyModule.getDocument.mockClear();
|
||||
pdfjsMock.loadingTask.destroy.mockClear();
|
||||
pdfjsMock.pdfDocument.destroy.mockClear();
|
||||
pdfjsMock.pdfDocument.getPage.mockClear();
|
||||
pdfjsMock.page.cleanup.mockClear();
|
||||
pdfjsMock.page.getViewport.mockClear();
|
||||
pdfjsMock.page.render.mockClear();
|
||||
pdfjsMock.createObjectURL.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads the canvas preview renderer from the legacy PDF.js runtime", async () => {
|
||||
await expect(import("./pdf-canvas")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
PdfCanvasDocument: expect.any(Function),
|
||||
PdfCanvasPage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates thumbnails with the legacy PDF.js runtime", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback) => {
|
||||
callback(new Blob(["png"], { type: "image/png" }));
|
||||
});
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL);
|
||||
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
|
||||
await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe(
|
||||
"blob:thumbnail",
|
||||
);
|
||||
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
);
|
||||
expect(pdfjsMock.legacyModule.getDocument).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.any(Uint8Array) }),
|
||||
);
|
||||
expect(pdfjsMock.page.render).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ annotationMode: 0, background: "white" }),
|
||||
);
|
||||
expect(pdfjsMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+4
-13
@@ -8,7 +8,6 @@ import { ResumePreviewClient } from "./preview.browser";
|
||||
|
||||
const previewMock = vi.hoisted(() => ({
|
||||
builderResumeData: undefined as ResumeData | undefined,
|
||||
localizedResumeData: [] as Array<ResumeData | undefined>,
|
||||
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
}));
|
||||
|
||||
@@ -23,18 +22,11 @@ const resumeDataWithPageCount = (pageCount: number): ResumeData => ({
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("@react-pdf/renderer", () => ({
|
||||
pdf: () => ({ toBlob: previewMock.toBlob }),
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
createResumePdfBlob: previewMock.toBlob,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({
|
||||
useLocalizedResumeDocument: (data?: ResumeData) => {
|
||||
previewMock.localizedResumeData.push(data);
|
||||
return data ? { type: "resume-document", data } : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./builder-resume-draft", () => ({
|
||||
vi.mock("../builder/draft", () => ({
|
||||
useResumeData: () => previewMock.builderResumeData,
|
||||
}));
|
||||
|
||||
@@ -84,7 +76,6 @@ vi.mock("./pdf-canvas", async () => {
|
||||
describe("ResumePreviewClient", () => {
|
||||
beforeEach(() => {
|
||||
previewMock.builderResumeData = undefined;
|
||||
previewMock.localizedResumeData = [];
|
||||
previewMock.toBlob.mockReset();
|
||||
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
|
||||
});
|
||||
@@ -109,6 +100,6 @@ describe("ResumePreviewClient", () => {
|
||||
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(previewMock.localizedResumeData).toContain(sampleResumeData);
|
||||
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
|
||||
});
|
||||
});
|
||||
+5
-7
@@ -1,11 +1,10 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document";
|
||||
import { useResumeData } from "./builder-resume-draft";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { useResumeData } from "../builder/draft";
|
||||
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
|
||||
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
|
||||
|
||||
@@ -93,7 +92,6 @@ export function ResumePreviewClient({
|
||||
}: ResolvedResumePreviewProps) {
|
||||
const builderResumeData = useResumeData();
|
||||
const resumeData = data ?? builderResumeData;
|
||||
const resumeDocument = useLocalizedResumeDocument(resumeData);
|
||||
|
||||
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
|
||||
|
||||
@@ -102,7 +100,7 @@ export function ResumePreviewClient({
|
||||
const hasPreviewRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeDocument) return;
|
||||
if (!resumeData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const requestId = ++requestIdRef.current;
|
||||
@@ -110,7 +108,7 @@ export function ResumePreviewClient({
|
||||
|
||||
const generatePdfPreview = async () => {
|
||||
try {
|
||||
const blob = await pdf(resumeDocument).toBlob();
|
||||
const blob = await createResumePdfBlob(resumeData);
|
||||
|
||||
if (cancelled || requestId !== requestIdRef.current) return;
|
||||
|
||||
@@ -129,7 +127,7 @@ export function ResumePreviewClient({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [resumeDocument]);
|
||||
}, [resumeData]);
|
||||
|
||||
if (!resumeData) return null;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { ResumePreviewProps } from "./preview.shared";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { useResumeData } from "./builder-resume-draft";
|
||||
import { useResumeData } from "../builder/draft";
|
||||
import { getResumePreviewPageCount, normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared";
|
||||
|
||||
const ResumePreviewClient = lazy(() =>
|
||||
+1
-1
@@ -29,7 +29,7 @@ const pdfViewerMock = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
createResumePdfBlob: pdfViewerMock.createResumePdfBlob,
|
||||
}));
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist";
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import "pdfjs-dist/legacy/web/pdf_viewer.css";
|
||||
import "./pdf-viewer.css";
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
|
||||
}));
|
||||
|
||||
+1
-1
@@ -8,8 +8,8 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { PdfViewer } from "./pdf-viewer";
|
||||
|
||||
const publicResumeRoute = getRouteApi("/$username/$slug");
|
||||
@@ -0,0 +1,35 @@
|
||||
import { motion } from "motion/react";
|
||||
import { useEnabledProviders } from "./components/hooks";
|
||||
import { PasskeysSection } from "./components/passkeys";
|
||||
import { PasswordSection } from "./components/password";
|
||||
import { SocialProviderSection } from "./components/social-provider";
|
||||
import { TwoFactorSection } from "./components/two-factor";
|
||||
|
||||
export function AuthenticationSettingsPage() {
|
||||
const { enabledProviders } = useEnabledProviders();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
|
||||
>
|
||||
<PasswordSection />
|
||||
|
||||
<TwoFactorSection />
|
||||
|
||||
<PasskeysSection />
|
||||
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
|
||||
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
|
||||
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
|
||||
|
||||
{"custom" in enabledProviders && (
|
||||
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { motion } from "motion/react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { AISettingsSection } from "./components/ai-section";
|
||||
|
||||
export function IntegrationsSettingsPage() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
|
||||
>
|
||||
<AISettingsSection />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon, KeyIcon, LinkSimpleIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
|
||||
export function ApiKeysSettingsPage() {
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
|
||||
const { data: apiKeys = [] } = useQuery({
|
||||
queryKey: ["auth", "api-keys"],
|
||||
queryFn: () => authClient.apiKey.list(),
|
||||
select: ({ data }) => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.apiKeys
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.filter((key) => !!key.expiresAt && key.expiresAt.getTime() > Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this API key?`, {
|
||||
description: t`The API key will no longer be able to access your data after deletion. This action cannot be undone.`,
|
||||
confirmText: t({
|
||||
comment: "API key deletion confirmation dialog confirm action in settings",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "API key deletion confirmation dialog cancel action in settings",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your API key...`);
|
||||
|
||||
const { error } = await authClient.apiKey.delete({ keyId: id });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting an API key fails",
|
||||
message: "Failed to delete the API key. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`The API key has been deleted successfully.`, { id: toastId });
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<BookOpenIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>How do I use the API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Explore the API documentation to learn how to integrate Reactive Resume with your applications. Find
|
||||
detailed endpoints, request examples, and authentication methods.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full py-3"
|
||||
onClick={() => openDialog("api-key.create", undefined)}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new API key</Trans>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
>
|
||||
<KeyIcon />
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-mono text-xs">{key.start}...</p>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<Trans>Expires on {key.expiresAt?.toLocaleDateString()}</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
const CONFIRMATION_TEXT = "delete";
|
||||
|
||||
export function DangerZoneSettingsPage() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const isConfirmationValid = confirmationText === CONFIRMATION_TEXT;
|
||||
|
||||
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
|
||||
description: t`This action cannot be undone. All your data will be permanently deleted.`,
|
||||
confirmText: t({
|
||||
comment: "Account deletion confirmation dialog confirm action in danger zone",
|
||||
message: "Confirm",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Account deletion confirmation dialog cancel action in danger zone",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your account...`);
|
||||
|
||||
deleteAccount(undefined, {
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Your account has been deleted successfully.`, { id: toastId });
|
||||
await authClient.signOut();
|
||||
void navigate({ to: "/" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when account deletion fails",
|
||||
message: "Failed to delete your account. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<p className="leading-relaxed">
|
||||
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
|
||||
</p>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="justify-self-end will-change-transform"
|
||||
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
|
||||
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { LocaleCombobox } from "@/features/locale/combobox";
|
||||
import { ThemeCombobox } from "@/features/theme/combobox";
|
||||
|
||||
export function PreferencesSettingsPage() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
<Trans>Theme</Trans>
|
||||
</Label>
|
||||
<ThemeCombobox />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
<Trans>Language</Trans>
|
||||
</Label>
|
||||
<LocaleCombobox />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-5 justify-start text-muted-foreground text-xs active:scale-100"
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Help translate the app to your language</Trans>
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { AuthSession } from "@reactive-resume/auth/types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().trim(),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export function ProfileSettingsPage({ session }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: session.user.name,
|
||||
username: session.user.username,
|
||||
email: session.user.email,
|
||||
},
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
name: value.name,
|
||||
username: value.username,
|
||||
displayUsername: value.username,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when updating profile details fails",
|
||||
message: "Failed to update your profile. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your profile has been updated successfully.`);
|
||||
form.reset({ name: value.name, username: value.username, email: session.user.email });
|
||||
void router.invalidate();
|
||||
|
||||
if (value.email !== session.user.email) {
|
||||
const { error } = await authClient.changeEmail({
|
||||
newEmail: value.email,
|
||||
callbackURL: "/dashboard/settings/profile",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when requesting email change confirmation fails",
|
||||
message: "Failed to request email change. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t`A confirmation link has been sent to your current email address. Please check your inbox to confirm the change.`,
|
||||
);
|
||||
form.reset({ name: value.name, username: value.username, email: session.user.email });
|
||||
void router.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const isDirty = useStore(form.store, (s) => s.isDirty);
|
||||
|
||||
const handleResendVerificationEmail = async () => {
|
||||
const toastId = toast.loading(t`Resending verification email...`);
|
||||
|
||||
const { error } = await authClient.sendVerificationEmail({
|
||||
email: session.user.email,
|
||||
callbackURL: "/dashboard/settings/profile",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resending account verification email fails",
|
||||
message: "Failed to resend verification email. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t`A new verification link has been sent to your email address. Please check your inbox to verify your account.`,
|
||||
{ id: toastId },
|
||||
);
|
||||
void router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on profile settings form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="username">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="username"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on profile settings form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on profile settings form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
{session.user.emailVerified === true ? (
|
||||
<p className="flex items-center gap-x-1.5 text-green-700 text-xs">
|
||||
<CheckIcon />
|
||||
<Trans>Verified</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
|
||||
<WarningIcon className="size-3.5" />
|
||||
<Trans>Unverified</Trans>
|
||||
<span>|</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto gap-x-1.5 p-0! text-inherit text-xs"
|
||||
onClick={handleResendVerificationEmail}
|
||||
>
|
||||
<Trans>Resend verification email</Trans>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.form>
|
||||
);
|
||||
}
|
||||
+1
-2
@@ -2,7 +2,7 @@ import type { SingleComboboxProps } from "@/components/ui/combobox";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { isTheme, setThemeServerFn, themeMap } from "@/libs/theme";
|
||||
import { isTheme, themeMap } from "@/libs/theme";
|
||||
import { useTheme } from "./provider";
|
||||
|
||||
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
|
||||
@@ -20,7 +20,6 @@ export function ThemeCombobox(props: Props) {
|
||||
|
||||
const onThemeChange = async (value: string | null) => {
|
||||
if (!value || !isTheme(value)) return;
|
||||
await setThemeServerFn({ data: value });
|
||||
setTheme(value);
|
||||
void router.invalidate();
|
||||
};
|
||||
+7
-3
@@ -1,8 +1,8 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { Theme } from "@/libs/theme";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { createContext, use } from "react";
|
||||
import { setThemeServerFn } from "@/libs/theme";
|
||||
import { createContext, use, useEffect } from "react";
|
||||
import { setThemeCookie } from "@/libs/theme";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
@@ -17,11 +17,15 @@ type Props = PropsWithChildren<{ theme: Theme }>;
|
||||
export function ThemeProvider({ children, theme }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}, [theme]);
|
||||
|
||||
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
|
||||
const { playSound = true } = options;
|
||||
|
||||
document.documentElement.classList.toggle("dark", value === "dark");
|
||||
await setThemeServerFn({ data: value });
|
||||
setThemeCookie(value);
|
||||
void router.invalidate();
|
||||
|
||||
if (!playSound) return;
|
||||
+4
-3
@@ -19,10 +19,10 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useTheme } from "@/components/theme/provider";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/libs/locale";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
import { isTheme } from "@/libs/theme";
|
||||
|
||||
type Props = {
|
||||
@@ -43,7 +43,8 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
// 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).
|
||||
// SPA session lookup. This stays in apps/web because @reactive-resume/auth is server-only.
|
||||
|
||||
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;
|
||||
});
|
||||
export const getSession = async (): Promise<AuthSession | null> => {
|
||||
const { data, error } = await authClient.getSession();
|
||||
if (error) return null;
|
||||
return data as AuthSession;
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { MessageDescriptor, Messages } from "@lingui/core";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
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 { localeSchema } from "@reactive-resume/utils/locale";
|
||||
|
||||
@@ -95,23 +93,15 @@ export function isRTL(locale: string): boolean {
|
||||
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 getLocale = () => {
|
||||
const locale = Cookies.get(storageKey);
|
||||
if (!locale || !isLocale(locale)) return defaultLocale;
|
||||
return locale;
|
||||
};
|
||||
|
||||
export const setLocaleServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(localeSchema)
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(storageKey, data);
|
||||
});
|
||||
export const setLocaleCookie = (locale: Locale) => {
|
||||
Cookies.set(storageKey, locale);
|
||||
};
|
||||
|
||||
const loadMessages = async (locale: Locale) => {
|
||||
const load = messageLoaders[`../../locales/${locale}.po`];
|
||||
|
||||
@@ -1,85 +1,54 @@
|
||||
import type { InferRouterInputs, InferRouterOutputs, RouterClient } from "@orpc/server";
|
||||
import type router from "@reactive-resume/api/routers";
|
||||
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();
|
||||
const getRpcUrl = () => {
|
||||
if (typeof window === "undefined") return "http://localhost:3000/api/rpc";
|
||||
return `${window.location.origin}/api/rpc`;
|
||||
};
|
||||
|
||||
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);
|
||||
const createRpcClient = (): RouterClient<typeof router> => {
|
||||
const link = new RPCLink({
|
||||
url: getRpcUrl(),
|
||||
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
|
||||
plugins: [
|
||||
new BatchLinkPlugin({
|
||||
mode: "streaming",
|
||||
groups: [{ condition: () => true, context: {} }],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("[oRPC client]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const client = getORPCClient();
|
||||
return createORPCClient(link);
|
||||
};
|
||||
|
||||
const getORPCStreamClient = 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();
|
||||
export const client = createRpcClient();
|
||||
|
||||
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" }),
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("[oRPC stream client]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return createORPCClient(link);
|
||||
const createStreamClient = (): RouterClient<typeof router> => {
|
||||
const link = new RPCLink({
|
||||
url: getRpcUrl(),
|
||||
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("[oRPC stream client]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const streamClient = getORPCStreamClient();
|
||||
return createORPCClient(link);
|
||||
};
|
||||
|
||||
export const streamClient = createStreamClient();
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client);
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pwaHeadMetaTags, pwaManifest, pwaServiceWorkerRegistrationScript } from "./pwa";
|
||||
|
||||
describe("pwaManifest", () => {
|
||||
it("uses the same theme color and background color (dark zinc)", () => {
|
||||
expect(pwaManifest.theme_color).toBe(pwaManifest.background_color);
|
||||
});
|
||||
|
||||
it("declares standalone display and portrait orientation", () => {
|
||||
expect(pwaManifest.display).toBe("standalone");
|
||||
expect(pwaManifest.orientation).toBe("portrait");
|
||||
});
|
||||
|
||||
it("scopes to root '/'", () => {
|
||||
expect(pwaManifest.scope).toBe("/");
|
||||
expect(pwaManifest.start_url).toContain("/");
|
||||
});
|
||||
|
||||
it("declares all icon sizes (64, 192, 512, maskable, favicon)", () => {
|
||||
const sizes = pwaManifest.icons?.map((i) => i.sizes) ?? [];
|
||||
expect(sizes).toContain("64x64");
|
||||
expect(sizes).toContain("192x192");
|
||||
expect(sizes).toContain("512x512");
|
||||
});
|
||||
|
||||
it("declares at least one maskable icon", () => {
|
||||
const maskable = pwaManifest.icons?.find((i) => i.purpose === "maskable");
|
||||
expect(maskable).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes both wide and narrow screenshots for proper installation UI", () => {
|
||||
const wide = pwaManifest.screenshots?.filter((s) => s.form_factor === "wide");
|
||||
const narrow = pwaManifest.screenshots?.filter((s) => s.form_factor === "narrow");
|
||||
expect(wide?.length).toBeGreaterThan(0);
|
||||
expect(narrow?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes 'resume' in categories for app stores", () => {
|
||||
expect(pwaManifest.categories).toContain("resume");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pwaHeadMetaTags", () => {
|
||||
it("includes theme-color meta tag", () => {
|
||||
expect(pwaHeadMetaTags).toContainEqual(expect.objectContaining({ name: "theme-color" }));
|
||||
});
|
||||
|
||||
it("declares apple-mobile-web-app capable", () => {
|
||||
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-capable");
|
||||
expect(tag?.content).toBe("yes");
|
||||
});
|
||||
|
||||
it("uses 'black-translucent' status bar style for iOS", () => {
|
||||
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-status-bar-style");
|
||||
expect(tag?.content).toBe("black-translucent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pwaServiceWorkerRegistrationScript", () => {
|
||||
it("registers the service worker only when supported", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('"serviceWorker" in navigator');
|
||||
});
|
||||
|
||||
it("registers /sw.js with root scope", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain("/sw.js");
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('scope: "/"');
|
||||
});
|
||||
|
||||
it("registers on window load to avoid blocking initial paint", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('addEventListener("load"');
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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: "/" });
|
||||
});
|
||||
}
|
||||
`;
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { createLocalizedResumeDocument } from "./pdf-document";
|
||||
|
||||
export const createResumePdfFile = async (data: ResumeData, filename: string, template?: Template) => {
|
||||
const document = await createLocalizedResumeDocument(data, template);
|
||||
const buffer = await renderToBuffer(document);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
|
||||
return new File([bytes], filename, { type: "application/pdf" });
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNoindexFollowMeta,
|
||||
createRootStructuredDataScript,
|
||||
getCanonicalRootUrl,
|
||||
getRootStructuredData,
|
||||
} from "./seo";
|
||||
|
||||
describe("getCanonicalRootUrl", () => {
|
||||
it("uses the production root when no origin is available", () => {
|
||||
expect(getCanonicalRootUrl()).toBe("https://rxresu.me/");
|
||||
});
|
||||
|
||||
it("normalizes an app origin to the root URL", () => {
|
||||
expect(getCanonicalRootUrl("http://localhost:3000")).toBe("http://localhost:3000/");
|
||||
expect(getCanonicalRootUrl("https://rxresu.me/")).toBe("https://rxresu.me/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNoindexFollowMeta", () => {
|
||||
it("returns the robots noindex metadata used by private app surfaces", () => {
|
||||
expect(createNoindexFollowMeta()).toEqual({ name: "robots", content: "noindex, follow" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRootStructuredDataScript", () => {
|
||||
it("serializes JSON-LD using the structured data script id", () => {
|
||||
const script = createRootStructuredDataScript("https://rxresu.me/");
|
||||
|
||||
expect(script.id).toBe("reactive-resume-structured-data");
|
||||
expect(script.type).toBe("application/ld+json");
|
||||
expect(JSON.parse(script.children)).toMatchObject({ "@context": "https://schema.org" });
|
||||
});
|
||||
|
||||
it("escapes script-breaking sequences in JSON-LD children", () => {
|
||||
const script = createRootStructuredDataScript("https://rxresu.me/</script><!---->\u2028\u2029");
|
||||
|
||||
expect(script.children).not.toContain("</script");
|
||||
expect(script.children).not.toContain("<!--");
|
||||
expect(script.children).not.toContain("\u2028");
|
||||
expect(script.children).not.toContain("\u2029");
|
||||
expect(script.children).toContain("\\u003C/script");
|
||||
expect(script.children).toContain("\\u003C!--");
|
||||
expect(script.children).toContain("\\u2028");
|
||||
expect(script.children).toContain("\\u2029");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRootStructuredData", () => {
|
||||
it("describes only conservative visible product facts", () => {
|
||||
const schemas = getRootStructuredData("https://rxresu.me/");
|
||||
|
||||
expect(schemas).toHaveLength(4);
|
||||
expect(schemas[0]).toMatchObject({
|
||||
"@type": "WebSite",
|
||||
name: "Reactive Resume",
|
||||
url: "https://rxresu.me/",
|
||||
});
|
||||
expect(schemas[1]).toMatchObject({
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
});
|
||||
expect(schemas[3]).toMatchObject({
|
||||
"@type": "FAQPage",
|
||||
mainEntity: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Is Reactive Resume really free?",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
const productionRootUrl = "https://rxresu.me/";
|
||||
const appName = "Reactive Resume";
|
||||
const repositoryUrl = "https://github.com/amruthpillai/reactive-resume";
|
||||
|
||||
type JsonLd = Record<string, unknown>;
|
||||
|
||||
export const getCanonicalRootUrl = (origin?: string): string => {
|
||||
if (!origin) return productionRootUrl;
|
||||
|
||||
const url = new URL(origin);
|
||||
url.pathname = "/";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const createNoindexFollowMeta = () => ({ name: "robots", content: "noindex, follow" });
|
||||
|
||||
const serializeJsonLdForScript = (data: JsonLd) =>
|
||||
JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, (character) => {
|
||||
switch (character) {
|
||||
case "<":
|
||||
return "\\u003C";
|
||||
case ">":
|
||||
return "\\u003E";
|
||||
case "&":
|
||||
return "\\u0026";
|
||||
case "\u2028":
|
||||
return "\\u2028";
|
||||
case "\u2029":
|
||||
return "\\u2029";
|
||||
default:
|
||||
return character;
|
||||
}
|
||||
});
|
||||
|
||||
const createStructuredDataScript = (id: string, data: JsonLd) => ({
|
||||
id,
|
||||
type: "application/ld+json",
|
||||
children: serializeJsonLdForScript(data),
|
||||
});
|
||||
|
||||
export const getRootStructuredData = (canonicalUrl: string): JsonLd[] => [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
name: appName,
|
||||
url: canonicalUrl,
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: appName,
|
||||
url: canonicalUrl,
|
||||
description:
|
||||
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
codeRepository: repositoryUrl,
|
||||
},
|
||||
{
|
||||
"@type": "Project",
|
||||
name: appName,
|
||||
url: canonicalUrl,
|
||||
sameAs: [repositoryUrl],
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: homeFaqJsonLdItems.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
export const createRootStructuredDataScript = (canonicalUrl: string) =>
|
||||
createStructuredDataScript("reactive-resume-structured-data", {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": getRootStructuredData(canonicalUrl),
|
||||
});
|
||||
|
||||
const homeFaqJsonLdItems = [
|
||||
{
|
||||
question: "Is Reactive Resume really free?",
|
||||
answer:
|
||||
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
|
||||
},
|
||||
{
|
||||
question: "How is my data protected?",
|
||||
answer:
|
||||
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
|
||||
},
|
||||
{
|
||||
question: "Can I export my resume to PDF?",
|
||||
answer:
|
||||
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
|
||||
},
|
||||
{
|
||||
question: "Is Reactive Resume available in multiple languages?",
|
||||
answer:
|
||||
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
|
||||
},
|
||||
{
|
||||
question: "What makes Reactive Resume different from other resume builders?",
|
||||
answer:
|
||||
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
|
||||
},
|
||||
{
|
||||
question: "How do I share my resume?",
|
||||
answer:
|
||||
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
|
||||
},
|
||||
] as const;
|
||||
@@ -1,7 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -21,20 +19,12 @@ 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 getTheme = () => {
|
||||
const theme = Cookies.get(storageKey);
|
||||
if (!theme || !isTheme(theme)) return defaultTheme;
|
||||
return theme;
|
||||
};
|
||||
|
||||
export const setThemeServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(themeSchema)
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(storageKey, data);
|
||||
});
|
||||
export const setThemeCookie = (theme: Theme) => {
|
||||
Cookies.set(storageKey, theme);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import "./polyfills/map-upsert";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { getRouter } from "./router";
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("app");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
const router = await getRouter();
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user