mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
feat(ai): implement an AI chat window for agentic resume building (#3022)
This commit is contained in:
@@ -46,7 +46,7 @@ import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import z from "zod";
|
||||
@@ -137,6 +137,11 @@ export function RichInput({ value, onChange, style, className, editorClassName,
|
||||
|
||||
const providerValue = useMemo(() => ({ editor }), [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.getHTML() === value) return;
|
||||
editor.commands.setContent(value, { emitUpdate: false });
|
||||
}, [editor, value]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const editorElement = (
|
||||
|
||||
@@ -2,22 +2,25 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { WritableDraft } from "immer";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { consumeEventIterator } from "@orpc/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { debounce } from "es-toolkit";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { debounce, isEqual } from "es-toolkit";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applyResumePatches, createResumePatches } from "@reactive-resume/utils/resume/patch";
|
||||
import { orpc, streamClient } from "@/libs/orpc/client";
|
||||
|
||||
type Resume = {
|
||||
export type Resume = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
data: ResumeData;
|
||||
isLocked: boolean;
|
||||
updatedAt: Date;
|
||||
hasPassword?: boolean;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
@@ -31,6 +34,8 @@ type ResumeStoreState = {
|
||||
type ResumeStoreActions = {
|
||||
initialize: (resume: Resume | null) => void;
|
||||
reset: () => void;
|
||||
replaceResumeDraft: (resume: Resume) => void;
|
||||
replaceResumeFromServer: (resume: Resume) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
|
||||
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
|
||||
mergeResumeMetadata: (resume: Resume) => void;
|
||||
@@ -41,6 +46,8 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
type Runtime = {
|
||||
abortController: AbortController;
|
||||
queryClient?: QueryClient;
|
||||
baselineData?: ResumeData;
|
||||
hasPendingLocalChanges: boolean;
|
||||
syncErrorToastId?: string | number;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
|
||||
beforeUnloadHandler?: () => void;
|
||||
@@ -55,6 +62,16 @@ function getResumeQueryKey(id: string): QueryKey {
|
||||
return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey;
|
||||
}
|
||||
|
||||
function cloneResumeData(data: ResumeData): ResumeData {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
function setRuntimeBaseline(resume: Resume) {
|
||||
const runtime = getRuntime(resume.id);
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
}
|
||||
|
||||
function createRuntime(): Runtime {
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -63,14 +80,37 @@ function createRuntime(): Runtime {
|
||||
const runtime = runtimes.get(resume.id);
|
||||
if (!runtime) return;
|
||||
|
||||
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
|
||||
const operations = createResumePatches(baselineData, resume.data);
|
||||
|
||||
if (operations.length === 0) {
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedData = cloneResumeData(resume.data);
|
||||
|
||||
try {
|
||||
const updated = (await orpc.resume.update.call(
|
||||
{ id: resume.id, data: resume.data },
|
||||
const updated = (await orpc.resume.patch.call(
|
||||
{ id: resume.id, operations },
|
||||
{ signal: abortController.signal },
|
||||
)) as Resume;
|
||||
|
||||
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
|
||||
useResumeStore.getState().mergeResumeMetadata(updated);
|
||||
runtime.baselineData = cloneResumeData(updated.data);
|
||||
|
||||
const currentResume = useResumeStore.getState().resume;
|
||||
const currentDataStillMatchesSubmission =
|
||||
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
|
||||
|
||||
if (currentDataStillMatchesSubmission) {
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
useResumeStore.getState().replaceResumeFromServer(updated);
|
||||
} else {
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
useResumeStore.getState().mergeResumeMetadata(updated);
|
||||
syncCurrentResume(resume.id);
|
||||
}
|
||||
|
||||
if (runtime.syncErrorToastId === undefined) return;
|
||||
toast.dismiss(runtime.syncErrorToastId);
|
||||
@@ -89,6 +129,7 @@ function createRuntime(): Runtime {
|
||||
|
||||
const runtime: Runtime = {
|
||||
abortController,
|
||||
hasPendingLocalChanges: false,
|
||||
syncResume,
|
||||
};
|
||||
|
||||
@@ -113,6 +154,10 @@ function bindRuntimeQueryClient(id: string, queryClient: QueryClient) {
|
||||
getRuntime(id).queryClient = queryClient;
|
||||
}
|
||||
|
||||
function hasPendingLocalChanges(id: string): boolean {
|
||||
return getRuntime(id).hasPendingLocalChanges;
|
||||
}
|
||||
|
||||
function cleanupRuntime(id: string) {
|
||||
const runtime = runtimes.get(id);
|
||||
if (!runtime) return;
|
||||
@@ -141,6 +186,8 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
isReady: false,
|
||||
|
||||
initialize: (resume) => {
|
||||
if (resume) setRuntimeBaseline(resume);
|
||||
|
||||
set((state) => {
|
||||
state.resume = resume;
|
||||
state.resumeId = resume?.id;
|
||||
@@ -156,6 +203,24 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
replaceResumeDraft: (resume) => {
|
||||
set((state) => {
|
||||
state.resume = resume;
|
||||
state.resumeId = resume.id;
|
||||
state.isReady = true;
|
||||
});
|
||||
},
|
||||
|
||||
replaceResumeFromServer: (resume) => {
|
||||
setRuntimeBaseline(resume);
|
||||
|
||||
set((state) => {
|
||||
state.resume = resume;
|
||||
state.resumeId = resume.id;
|
||||
state.isReady = true;
|
||||
});
|
||||
},
|
||||
|
||||
patchResume: (fn) => {
|
||||
set((state) => {
|
||||
if (!state.resume) return;
|
||||
@@ -171,6 +236,7 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
state.resume.slug = resume.slug;
|
||||
state.resume.tags = resume.tags;
|
||||
state.resume.isLocked = resume.isLocked;
|
||||
state.resume.updatedAt = resume.updatedAt;
|
||||
state.resume.hasPassword = resume.hasPassword;
|
||||
state.resume.isPublic = resume.isPublic;
|
||||
});
|
||||
@@ -192,6 +258,7 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
fn(state.resume.data as WritableDraft<ResumeData>);
|
||||
});
|
||||
|
||||
getRuntime(currentResume.id).hasPendingLocalChanges = true;
|
||||
syncCurrentResume(currentResume.id);
|
||||
},
|
||||
})),
|
||||
@@ -213,6 +280,20 @@ export function usePatchResume() {
|
||||
return useResumeStore((state) => state.patchResume);
|
||||
}
|
||||
|
||||
export function useReplaceResumeFromServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
|
||||
|
||||
return useCallback(
|
||||
(resume: Resume) => {
|
||||
bindRuntimeQueryClient(resume.id, queryClient);
|
||||
queryClient.setQueryData(getResumeQueryKey(resume.id), resume);
|
||||
replaceResumeFromServer(resume);
|
||||
},
|
||||
[queryClient, replaceResumeFromServer],
|
||||
);
|
||||
}
|
||||
|
||||
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
@@ -259,6 +340,68 @@ export function useUpdateResumeData() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useResumeUpdateSubscription() {
|
||||
const queryClient = useQueryClient();
|
||||
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
const [_retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeId) return;
|
||||
|
||||
bindRuntimeQueryClient(resumeId, queryClient);
|
||||
|
||||
let didCancel = false;
|
||||
let retryTimer: number | undefined;
|
||||
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
|
||||
onEvent: async () => {
|
||||
try {
|
||||
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
||||
|
||||
if (hasPendingLocalChanges(resumeId)) {
|
||||
const runtime = getRuntime(resumeId);
|
||||
const currentResume = useResumeStore.getState().resume;
|
||||
const baselineData = runtime.baselineData ?? currentResume?.data;
|
||||
|
||||
if (currentResume && baselineData) {
|
||||
const localOperations = createResumePatches(baselineData, currentResume.data);
|
||||
const mergedData = applyResumePatches(resume.data, localOperations);
|
||||
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
runtime.hasPendingLocalChanges = localOperations.length > 0;
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
|
||||
syncCurrentResume(resumeId);
|
||||
} else {
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
useResumeStore.getState().mergeResumeMetadata(resume);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
replaceResumeFromServer(resume);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("Failed to refresh resume after update event:", error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (didCancel) return;
|
||||
console.warn("Resume update stream failed, reconnecting:", error);
|
||||
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
void cancel().catch(() => {});
|
||||
};
|
||||
}, [queryClient, replaceResumeFromServer, resumeId]);
|
||||
}
|
||||
|
||||
export function useResumeCleanup() {
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isEqual } from "es-toolkit";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type ResettableForm<TValues> = {
|
||||
reset: (values: TValues) => void;
|
||||
state: {
|
||||
values: TValues;
|
||||
};
|
||||
};
|
||||
|
||||
export function useSyncFormValues<TValues>(form: ResettableForm<TValues>, values: TValues) {
|
||||
useEffect(() => {
|
||||
if (isEqual(form.state.values, values)) return;
|
||||
form.reset(values);
|
||||
}, [form, values]);
|
||||
}
|
||||
@@ -48,6 +48,39 @@ const getORPCClient = createIsomorphicFn()
|
||||
|
||||
export const client = getORPCClient();
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
export const streamClient = getORPCStreamClient();
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client);
|
||||
|
||||
export type RouterInput = InferRouterInputs<typeof router>;
|
||||
|
||||
@@ -41,7 +41,6 @@ import { Route as DashboardSettingsPreferencesRouteImport } from './routes/dashb
|
||||
import { Route as DashboardSettingsJobSearchRouteImport } from './routes/dashboard/settings/job-search'
|
||||
import { Route as DashboardSettingsDangerZoneRouteImport } from './routes/dashboard/settings/danger-zone'
|
||||
import { Route as DashboardSettingsApiKeysRouteImport } from './routes/dashboard/settings/api-keys'
|
||||
import { Route as DashboardSettingsAiRouteImport } from './routes/dashboard/settings/ai'
|
||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||
import { Route as ApiOpenapiSplatRouteImport } from './routes/api/openapi.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
@@ -219,11 +218,6 @@ const DashboardSettingsApiKeysRoute =
|
||||
path: '/settings/api-keys',
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any)
|
||||
const DashboardSettingsAiRoute = DashboardSettingsAiRouteImport.update({
|
||||
id: '/settings/ai',
|
||||
path: '/settings/ai',
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any)
|
||||
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
||||
id: '/api/rpc/$',
|
||||
path: '/api/rpc/$',
|
||||
@@ -306,7 +300,6 @@ export interface FileRoutesByFullPath {
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/openapi/$': typeof ApiOpenapiSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/dashboard/settings/ai': typeof DashboardSettingsAiRoute
|
||||
'/dashboard/settings/api-keys': typeof DashboardSettingsApiKeysRoute
|
||||
'/dashboard/settings/danger-zone': typeof DashboardSettingsDangerZoneRoute
|
||||
'/dashboard/settings/job-search': typeof DashboardSettingsJobSearchRoute
|
||||
@@ -346,7 +339,6 @@ export interface FileRoutesByTo {
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/openapi/$': typeof ApiOpenapiSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/dashboard/settings/ai': typeof DashboardSettingsAiRoute
|
||||
'/dashboard/settings/api-keys': typeof DashboardSettingsApiKeysRoute
|
||||
'/dashboard/settings/danger-zone': typeof DashboardSettingsDangerZoneRoute
|
||||
'/dashboard/settings/job-search': typeof DashboardSettingsJobSearchRoute
|
||||
@@ -391,7 +383,6 @@ export interface FileRoutesById {
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/openapi/$': typeof ApiOpenapiSplatRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
'/dashboard/settings/ai': typeof DashboardSettingsAiRoute
|
||||
'/dashboard/settings/api-keys': typeof DashboardSettingsApiKeysRoute
|
||||
'/dashboard/settings/danger-zone': typeof DashboardSettingsDangerZoneRoute
|
||||
'/dashboard/settings/job-search': typeof DashboardSettingsJobSearchRoute
|
||||
@@ -436,7 +427,6 @@ export interface FileRouteTypes {
|
||||
| '/api/auth/$'
|
||||
| '/api/openapi/$'
|
||||
| '/api/rpc/$'
|
||||
| '/dashboard/settings/ai'
|
||||
| '/dashboard/settings/api-keys'
|
||||
| '/dashboard/settings/danger-zone'
|
||||
| '/dashboard/settings/job-search'
|
||||
@@ -476,7 +466,6 @@ export interface FileRouteTypes {
|
||||
| '/api/auth/$'
|
||||
| '/api/openapi/$'
|
||||
| '/api/rpc/$'
|
||||
| '/dashboard/settings/ai'
|
||||
| '/dashboard/settings/api-keys'
|
||||
| '/dashboard/settings/danger-zone'
|
||||
| '/dashboard/settings/job-search'
|
||||
@@ -520,7 +509,6 @@ export interface FileRouteTypes {
|
||||
| '/api/auth/$'
|
||||
| '/api/openapi/$'
|
||||
| '/api/rpc/$'
|
||||
| '/dashboard/settings/ai'
|
||||
| '/dashboard/settings/api-keys'
|
||||
| '/dashboard/settings/danger-zone'
|
||||
| '/dashboard/settings/job-search'
|
||||
@@ -781,13 +769,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DashboardSettingsApiKeysRouteImport
|
||||
parentRoute: typeof DashboardRouteRoute
|
||||
}
|
||||
'/dashboard/settings/ai': {
|
||||
id: '/dashboard/settings/ai'
|
||||
path: '/settings/ai'
|
||||
fullPath: '/dashboard/settings/ai'
|
||||
preLoaderRoute: typeof DashboardSettingsAiRouteImport
|
||||
parentRoute: typeof DashboardRouteRoute
|
||||
}
|
||||
'/api/rpc/$': {
|
||||
id: '/api/rpc/$'
|
||||
path: '/api/rpc/$'
|
||||
@@ -897,7 +878,6 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
|
||||
interface DashboardRouteRouteChildren {
|
||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute
|
||||
DashboardSettingsAiRoute: typeof DashboardSettingsAiRoute
|
||||
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute
|
||||
DashboardSettingsDangerZoneRoute: typeof DashboardSettingsDangerZoneRoute
|
||||
DashboardSettingsJobSearchRoute: typeof DashboardSettingsJobSearchRoute
|
||||
@@ -911,7 +891,6 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||
DashboardIndexRoute: DashboardIndexRoute,
|
||||
DashboardSettingsIntegrationsRouteRoute:
|
||||
DashboardSettingsIntegrationsRouteRoute,
|
||||
DashboardSettingsAiRoute: DashboardSettingsAiRoute,
|
||||
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
|
||||
DashboardSettingsDangerZoneRoute: DashboardSettingsDangerZoneRoute,
|
||||
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand/react";
|
||||
|
||||
type BuilderAssistantStore = {
|
||||
isOpen: boolean;
|
||||
setOpen: (isOpen: boolean) => void;
|
||||
toggleOpen: () => void;
|
||||
};
|
||||
|
||||
export const useBuilderAssistantStore = create<BuilderAssistantStore>((set) => ({
|
||||
isOpen: false,
|
||||
setOpen: (isOpen) => set({ isOpen }),
|
||||
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
AlignCenterHorizontalIcon,
|
||||
AlignTopIcon,
|
||||
ChatCircleDotsIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
@@ -26,6 +27,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume } from "@/components/resume/builder-resume-draft";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { useBuilderAssistantStore } from "./assistant-store";
|
||||
|
||||
type BuilderDockProps = {
|
||||
pageLayout: BuilderPreviewPageLayout;
|
||||
@@ -40,6 +42,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
const { zoomIn, zoomOut, centerView } = useControls();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const isAssistantOpen = useBuilderAssistantStore((state) => state.isOpen);
|
||||
const toggleAssistant = useBuilderAssistantStore((state) => state.toggleOpen);
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
@@ -108,6 +112,12 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
title={t`Toggle page stacking`}
|
||||
onClick={onTogglePageLayout}
|
||||
/>
|
||||
<DockIcon
|
||||
icon={ChatCircleDotsIcon}
|
||||
title={isAssistantOpen ? t`Close AI assistant` : t`Open AI assistant`}
|
||||
onClick={toggleAssistant}
|
||||
active={isAssistantOpen}
|
||||
/>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
@@ -130,9 +140,10 @@ type DockIconProps = {
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
iconClassName?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName, active }: DockIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -143,7 +154,14 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
className={cn(active && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary")}
|
||||
onClick={onClick}
|
||||
aria-label={title}
|
||||
>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { CustomFieldsSection } from "./custom-fields";
|
||||
@@ -38,6 +39,7 @@ function BasicsSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, basics);
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
@@ -73,6 +74,7 @@ function PictureSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, picture);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { LevelTypeCombobox } from "@/components/level/combobox";
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -45,6 +46,7 @@ function ColorSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, colors);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
@@ -266,6 +268,7 @@ function LevelSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, levelDesign);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../../shared/section-base";
|
||||
import { LayoutPages } from "./pages";
|
||||
@@ -45,6 +46,7 @@ function LayoutSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, { sidebarWidth: layout.sidebarWidth });
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { getLocaleOptions } from "@/components/locale/combobox";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -46,6 +47,7 @@ function PageSectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, page);
|
||||
|
||||
const handleAutoSave = <K extends keyof FormValues>(name: K, value: FormValues[K]) => {
|
||||
persist({ ...form.state.values, [name]: value });
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/builder-resume-draft";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -47,6 +48,7 @@ function TypographySectionForm() {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, typography);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
useMergeResumeMetadata,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
useResumeUpdateSubscription,
|
||||
} from "@/components/resume/builder-resume-draft";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { BuilderAssistant } from "./-components/assistant";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
@@ -59,6 +61,7 @@ function RouteComponent() {
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
|
||||
useResumeCleanup();
|
||||
useResumeUpdateSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
@@ -76,6 +79,7 @@ function RouteComponent() {
|
||||
resume.isLocked,
|
||||
resume.isPublic,
|
||||
resume.hasPassword,
|
||||
resume.updatedAt,
|
||||
resume,
|
||||
]);
|
||||
|
||||
@@ -164,6 +168,8 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
|
||||
<BuilderAssistant />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
|
||||
},
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -240,9 +239,9 @@ function AIForm() {
|
||||
{isTesting ? (
|
||||
<Spinner />
|
||||
) : testStatus === "success" ? (
|
||||
<CheckCircleIcon className="text-success" />
|
||||
<CheckCircleIcon className="text-emerald-500" />
|
||||
) : testStatus === "failure" ? (
|
||||
<XCircleIcon className="text-destructive" />
|
||||
<XCircleIcon className="text-rose-500" />
|
||||
) : null}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
@@ -288,8 +287,8 @@ export function AISettingsSection() {
|
||||
<Switch id="enable-ai" checked={aiEnabled} disabled={!canEnableAI} onCheckedChange={setAIEnabled} />
|
||||
</div>
|
||||
|
||||
<p className={cn("flex items-center gap-x-2", aiEnabled ? "text-success" : "text-destructive")}>
|
||||
{aiEnabled ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
<p className="flex items-center gap-x-2">
|
||||
{aiEnabled ? <CheckCircleIcon className="text-emerald-500" /> : <XCircleIcon className="text-rose-500" />}
|
||||
{aiEnabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user