mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-14 08:42:08 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
0
apps/client/src/assets/.gitkeep
Normal file
0
apps/client/src/assets/.gitkeep
Normal file
121
apps/client/src/components/ai-actions.tsx
Normal file
121
apps/client/src/components/ai-actions.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import {
|
||||
CaretDown,
|
||||
ChatTeardropText,
|
||||
CircleNotch,
|
||||
Exam,
|
||||
MagicWand,
|
||||
PenNib,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
import { changeTone } from "../services/openai/change-tone";
|
||||
import { fixGrammar } from "../services/openai/fix-grammar";
|
||||
import { improveWriting } from "../services/openai/improve-writing";
|
||||
import { useOpenAiStore } from "../stores/openai";
|
||||
|
||||
type Action = "improve" | "fix" | "tone";
|
||||
type Mood = "casual" | "professional" | "confident" | "friendly";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const AiActions = ({ value, onChange, className }: Props) => {
|
||||
const [loading, setLoading] = useState<Action | false>(false);
|
||||
const aiEnabled = useOpenAiStore((state) => !!state.apiKey);
|
||||
|
||||
if (!aiEnabled) return null;
|
||||
|
||||
const onClick = async (action: Action, mood?: Mood) => {
|
||||
setLoading(action);
|
||||
let result = value;
|
||||
|
||||
// await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
if (action === "improve") result = await improveWriting(value);
|
||||
if (action === "fix") result = await fixGrammar(value);
|
||||
if (action === "tone" && mood) result = await changeTone(value, mood);
|
||||
|
||||
onChange("Result" + result);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mt-4 rounded bg-secondary-accent/50 p-3 outline outline-secondary-accent",
|
||||
"flex flex-wrap items-center justify-center gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute -left-5 z-10">
|
||||
<Badge
|
||||
outline
|
||||
variant="primary"
|
||||
className="-rotate-90 bg-background px-2 text-[10px] leading-[10px]"
|
||||
>
|
||||
<MagicWand size={10} className="mr-1" />
|
||||
AI
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("improve")}>
|
||||
{loading === "improve" ? <CircleNotch className="animate-spin" /> : <PenNib />}
|
||||
<span className="ml-2 text-xs">Improve Writing</span>
|
||||
</Button>
|
||||
|
||||
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("fix")}>
|
||||
{loading === "fix" ? <CircleNotch className="animate-spin" /> : <Exam />}
|
||||
<span className="ml-2 text-xs">Fix Spelling & Grammar</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled={!!loading}>
|
||||
{loading === "tone" ? <CircleNotch className="animate-spin" /> : <ChatTeardropText />}
|
||||
<span className="mx-2 text-xs">Change Tone</span>
|
||||
<CaretDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onClick("tone", "casual")}>
|
||||
<span role="img" aria-label="Casual">
|
||||
🙂
|
||||
</span>
|
||||
<span className="ml-2">Casual</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onClick("tone", "professional")}>
|
||||
<span role="img" aria-label="Professional">
|
||||
💼
|
||||
</span>
|
||||
<span className="ml-2">Professional</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onClick("tone", "confident")}>
|
||||
<span role="img" aria-label="Confident">
|
||||
😎
|
||||
</span>
|
||||
<span className="ml-2">Confident</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onClick("tone", "friendly")}>
|
||||
<span role="img" aria-label="Friendly">
|
||||
😊
|
||||
</span>
|
||||
<span className="ml-2">Friendly</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
apps/client/src/components/copyright.tsx
Normal file
34
apps/client/src/components/copyright.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Copyright = ({ className }: Props) => (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-zinc flex max-w-none flex-col gap-y-1 text-xs opacity-40 dark:prose-invert",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
Licensed under{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
</span>
|
||||
<span>By the community, for the community.</span>
|
||||
<span>
|
||||
A passion project by{" "}
|
||||
<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.amruthpillai.com/">
|
||||
Amruth Pillai
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span className="mt-2 font-bold">Reactive Resume v{appVersion}</span>
|
||||
</div>
|
||||
);
|
||||
32
apps/client/src/components/icon.tsx
Normal file
32
apps/client/src/components/icon.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon = ({ size = 32, className }: Props) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
switch (isDarkMode) {
|
||||
case false:
|
||||
src = "/icon/dark.svg";
|
||||
break;
|
||||
case true:
|
||||
src = "/icon/light.svg";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="Reactive Resume"
|
||||
className={cn("rounded-sm", className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
32
apps/client/src/components/logo.tsx
Normal file
32
apps/client/src/components/logo.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Logo = ({ size = 32, className }: Props) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
switch (isDarkMode) {
|
||||
case false:
|
||||
src = "/logo/light.svg";
|
||||
break;
|
||||
case true:
|
||||
src = "/logo/dark.svg";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="Reactive Resume"
|
||||
className={cn("rounded-sm", className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
33
apps/client/src/components/theme-switch.tsx
Normal file
33
apps/client/src/components/theme-switch.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { CloudSun, Moon, Sun } from "@phosphor-icons/react";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ThemeSwitch = ({ size = 20 }: Props) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const variants: Variants = useMemo(() => {
|
||||
return {
|
||||
light: { x: 0 },
|
||||
system: { x: size * -1 },
|
||||
dark: { x: size * -2 },
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
return (
|
||||
<Button size="icon" variant="ghost" onClick={toggleTheme}>
|
||||
<div className="cursor-pointer overflow-hidden" style={{ width: size, height: size }}>
|
||||
<motion.div animate={theme} variants={variants} className="flex">
|
||||
<Sun size={size} className="shrink-0" />
|
||||
<CloudSun size={size} className="shrink-0" />
|
||||
<Moon size={size} className="shrink-0" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
40
apps/client/src/components/user-avatar.tsx
Normal file
40
apps/client/src/components/user-avatar.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { getInitials } from "@reactive-resume/utils";
|
||||
|
||||
import { useUser } from "../services/user";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const UserAvatar = ({ size = 36, className }: Props) => {
|
||||
const { user } = useUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
let picture: React.ReactNode = null;
|
||||
|
||||
if (!user.picture) {
|
||||
const initials = getInitials(user.name);
|
||||
|
||||
picture = (
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
className="flex items-center justify-center rounded-full bg-secondary text-center text-[10px] font-semibold text-secondary-foreground"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
picture = (
|
||||
<img
|
||||
alt={user.name}
|
||||
src={user.picture}
|
||||
className="rounded-full"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={className}>{picture}</div>;
|
||||
};
|
||||
38
apps/client/src/components/user-options.tsx
Normal file
38
apps/client/src/components/user-options.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
KeyboardShortcut,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useLogout } from "../services/auth";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const UserOptions = ({ children }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useLogout();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="top" align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")}>
|
||||
Settings
|
||||
<KeyboardShortcut>⇧S</KeyboardShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
Logout
|
||||
<KeyboardShortcut>⇧Q</KeyboardShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
20
apps/client/src/constants/colors.ts
Normal file
20
apps/client/src/constants/colors.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const colors: string[] = [
|
||||
"#78716c", // stone-500
|
||||
"#ef4444", // red-500
|
||||
"#f97316", // orange-500
|
||||
"#f59e0b", // amber-500
|
||||
"#eab308", // yellow-500
|
||||
"#84cc16", // lime-500
|
||||
"#22c55e", // green-500
|
||||
"#10b981", // emerald-500
|
||||
"#14b8a6", // teal-500
|
||||
"#06b6d4", // cyan-500
|
||||
"#0ea5e9", // sky-500
|
||||
"#3b82f6", // blue-500
|
||||
"#6366f1", // indigo-500
|
||||
"#8b5cf6", // violet-500
|
||||
"#a855f7", // purple-500
|
||||
"#d946ef", // fuchsia-500
|
||||
"#ec4899", // pink-500
|
||||
"#f43f5e", // rose-500
|
||||
];
|
||||
11
apps/client/src/constants/parallax-tilt.ts
Normal file
11
apps/client/src/constants/parallax-tilt.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactParallaxTiltProps } from "react-parallax-tilt";
|
||||
|
||||
export const defaultTiltProps: ReactParallaxTiltProps = {
|
||||
scale: 1.05,
|
||||
tiltMaxAngleX: 8,
|
||||
tiltMaxAngleY: 8,
|
||||
perspective: 1400,
|
||||
glareEnable: true,
|
||||
glareMaxOpacity: 0.1,
|
||||
glareColor: "#fafafa",
|
||||
};
|
||||
7
apps/client/src/constants/query-keys.ts
Normal file
7
apps/client/src/constants/query-keys.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const USER_KEY: QueryKey = ["user"];
|
||||
|
||||
export const RESUME_KEY: QueryKey = ["resume"];
|
||||
export const RESUMES_KEY: QueryKey = ["resumes"];
|
||||
export const RESUME_PREVIEW_KEY: QueryKey = ["resume", "preview"];
|
||||
177
apps/client/src/hooks/use-toast.ts
Normal file
177
apps/client/src/hooks/use-toast.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ToastActionElement, ToastProps } from "@reactive-resume/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 5000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
icon?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = createId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = useState<State>(memoryState);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
|
||||
if (index > -1) listeners.splice(index, 1);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
||||
53
apps/client/src/libs/axios.ts
Normal file
53
apps/client/src/libs/axios.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { deepSearchAndParseDates } from "@reactive-resume/utils";
|
||||
import _axios from "axios";
|
||||
import createAuthRefreshInterceptor from "axios-auth-refresh";
|
||||
import { redirect } from "react-router-dom";
|
||||
|
||||
import { USER_KEY } from "../constants/query-keys";
|
||||
import { refresh } from "../services/auth/refresh";
|
||||
import { queryClient } from "./query-client";
|
||||
|
||||
export type ServerError = {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const axios = _axios.create({ baseURL: "/api", withCredentials: true });
|
||||
|
||||
// Intercept responses to transform ISO dates to JS date objects
|
||||
axios.interceptors.response.use((response) => {
|
||||
const transformedResponse = deepSearchAndParseDates(response.data, ["createdAt", "updatedAt"]);
|
||||
return { ...response, data: transformedResponse };
|
||||
});
|
||||
|
||||
// Create another instance to handle failed refresh tokens
|
||||
// Reference: https://github.com/Flyrell/axios-auth-refresh/issues/191
|
||||
const axiosForRefresh = _axios.create({ baseURL: "/api", withCredentials: true });
|
||||
|
||||
// Interceptor to handle expired access token errors
|
||||
const handleAuthError = async () => {
|
||||
try {
|
||||
await refresh(axiosForRefresh);
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Interceptor to handle expired refresh token errors
|
||||
const handleRefreshError = async () => {
|
||||
try {
|
||||
queryClient.invalidateQueries({ queryKey: USER_KEY });
|
||||
redirect("/auth/login");
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Intercept responses to check for 401 and 403 errors, refresh token and retry the request
|
||||
createAuthRefreshInterceptor(axios, handleAuthError, { statusCodes: [401, 403] });
|
||||
createAuthRefreshInterceptor(axiosForRefresh, handleRefreshError);
|
||||
6
apps/client/src/libs/dayjs.ts
Normal file
6
apps/client/src/libs/dayjs.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(relativeTime);
|
||||
16
apps/client/src/libs/query-client.ts
Normal file
16
apps/client/src/libs/query-client.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
},
|
||||
},
|
||||
});
|
||||
13
apps/client/src/main.tsx
Normal file
13
apps/client/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { router } from "./router";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
20
apps/client/src/pages/auth/_components/social-auth.tsx
Normal file
20
apps/client/src/pages/auth/_components/social-auth.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { GithubLogo, GoogleLogo } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
|
||||
export const SocialAuth = () => (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button asChild size="lg" className="w-full !bg-[#222] !text-white hover:!bg-[#222]/80">
|
||||
<a href="/api/auth/github">
|
||||
<GoogleLogo className="mr-3 h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" className="w-full !bg-[#4285F4] !text-white hover:!bg-[#4285F4]/80">
|
||||
<a href="/api/auth/google">
|
||||
<GithubLogo className="mr-3 h-4 w-4" />
|
||||
Google
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
102
apps/client/src/pages/auth/backup-otp/page.tsx
Normal file
102
apps/client/src/pages/auth/backup-otp/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Warning } from "@phosphor-icons/react";
|
||||
import { twoFactorBackupSchema } from "@reactive-resume/dto";
|
||||
import { usePasswordToggle } from "@reactive-resume/hooks";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { useBackupOtp } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof twoFactorBackupSchema>;
|
||||
|
||||
export const BackupOtpPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { backupOtp, loading } = useBackupOtp();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
usePasswordToggle(formRef);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(twoFactorBackupSchema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await backupOtp(data);
|
||||
|
||||
navigate("/dashboard");
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to sign in",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Use your backup code</h2>
|
||||
<h6 className="leading-relaxed opacity-60">
|
||||
Enter one of the 10 backup codes you saved when you enabled two-factor authentication.
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name="code"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backup Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
pattern="[a-z0-9]{10}"
|
||||
placeholder="a1b2c3d4e5"
|
||||
title="may contain lowercase letters or numbers, and must be exactly 10 characters."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
apps/client/src/pages/auth/forgot-password/page.tsx
Normal file
105
apps/client/src/pages/auth/forgot-password/page.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Warning } from "@phosphor-icons/react";
|
||||
import { forgotPasswordSchema } from "@reactive-resume/dto";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { useForgotPassword } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
export const ForgotPasswordPage = () => {
|
||||
const [submitted, setSubmitted] = useState<boolean>(false);
|
||||
const { forgotPassword, loading } = useForgotPassword();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await forgotPassword(data);
|
||||
setSubmitted(true);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to send your password recovery email",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">You've got mail!</h2>
|
||||
<Alert variant="success">
|
||||
<AlertDescription className="pt-0">
|
||||
A password reset link should have been sent to your inbox, if an account existed with
|
||||
the email you provided.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Forgot your password?</h2>
|
||||
<h6 className="leading-relaxed opacity-75">
|
||||
Enter your email address and we will send you a link to reset your password if the account
|
||||
exists.
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john.doe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||
Send Email
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
apps/client/src/pages/auth/layout.tsx
Normal file
58
apps/client/src/pages/auth/layout.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, matchRoutes, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/client/components/logo";
|
||||
|
||||
import { SocialAuth } from "./_components/social-auth";
|
||||
|
||||
const authRoutes = [{ path: "/auth/login" }, { path: "/auth/register" }];
|
||||
|
||||
export const AuthLayout = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isAuthRoute = useMemo(() => matchRoutes(authRoutes, location) !== null, [location]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen">
|
||||
<div className="flex w-full flex-col justify-center gap-y-8 px-12 sm:mx-auto sm:basis-[420px] sm:px-0 lg:basis-[480px] lg:px-12">
|
||||
<Link to="/" className="h-24 w-24">
|
||||
<Logo className="-ml-3" size={96} />
|
||||
</Link>
|
||||
|
||||
<Outlet />
|
||||
|
||||
{isAuthRoute && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<hr className="flex-1" />
|
||||
<span className="text-xs font-medium">or continue with</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative hidden lg:block lg:flex-1">
|
||||
<img
|
||||
width={1920}
|
||||
height={1080}
|
||||
alt="Open books on a table"
|
||||
className="h-screen w-full object-cover object-center"
|
||||
src="/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-5 right-5 z-10 bg-primary/30 px-4 py-2 text-xs font-medium text-primary-foreground backdrop-blur-sm">
|
||||
<a
|
||||
href="https://unsplash.com/photos/Oaqk7qqNh_c"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
Photograph by Patrick Tomasso
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
apps/client/src/pages/auth/login/page.tsx
Normal file
126
apps/client/src/pages/auth/login/page.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Warning } from "@phosphor-icons/react";
|
||||
import { loginSchema } from "@reactive-resume/dto";
|
||||
import { usePasswordToggle } from "@reactive-resume/hooks";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useLogin } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { toast } = useToast();
|
||||
const { login, loading } = useLogin();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
usePasswordToggle(formRef);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await login(data);
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to sign in",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Sign in to your account</h2>
|
||||
<h6>
|
||||
<span className="opacity-75">Don't have an account?</span>
|
||||
<Button asChild variant="link" className="px-1.5">
|
||||
<Link to="/auth/register">
|
||||
Create one now <ArrowRight className="ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name="identifier"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john.doe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>You can also enter your username.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||
temporarily.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center gap-x-4">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="link" className="px-4">
|
||||
<Link to="/auth/forgot-password">Forgot Password?</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
apps/client/src/pages/auth/register/page.tsx
Normal file
155
apps/client/src/pages/auth/register/page.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Warning } from "@phosphor-icons/react";
|
||||
import { registerSchema } from "@reactive-resume/dto";
|
||||
import { usePasswordToggle } from "@reactive-resume/hooks";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { useRegister } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof registerSchema>;
|
||||
|
||||
export const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register, loading } = useRegister();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
usePasswordToggle(formRef);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
language: "en",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await register(data);
|
||||
|
||||
navigate("/auth/verify-email");
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to sign up",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Create a new account</h2>
|
||||
<h6>
|
||||
<span className="opacity-75">Already have an account?</span>
|
||||
<Button asChild variant="link" className="px-1.5">
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRight className="ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john.doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john.doe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||
temporarily.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button disabled={loading} className="mt-4 w-full">
|
||||
Sign up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
apps/client/src/pages/auth/reset-password/page.tsx
Normal file
110
apps/client/src/pages/auth/reset-password/page.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Warning } from "@phosphor-icons/react";
|
||||
import { resetPasswordSchema } from "@reactive-resume/dto";
|
||||
import { usePasswordToggle } from "@reactive-resume/hooks";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { useResetPassword } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
export const ResetPasswordPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token") || "";
|
||||
|
||||
const { resetPassword, loading } = useResetPassword();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
usePasswordToggle(formRef);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
defaultValues: { token, password: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await resetPassword(data);
|
||||
|
||||
navigate("/auth/login");
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to reset your password",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect the user to the forgot password page if the token is not present.
|
||||
useEffect(() => {
|
||||
if (!token) navigate("/auth/forgot-password");
|
||||
}, [token, navigate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Reset your password</h2>
|
||||
<h6 className="leading-relaxed opacity-75">
|
||||
Enter a new password below, and make sure it's secure.
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||
temporarily.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||
Update Password
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
apps/client/src/pages/auth/verify-email/page.tsx
Normal file
80
apps/client/src/pages/auth/verify-email/page.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { ArrowRight, Info, SealCheck, Warning } from "@phosphor-icons/react";
|
||||
import { Alert, AlertDescription, AlertTitle, Button } from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { useVerifyEmail } from "@/client/services/auth";
|
||||
|
||||
export const VerifyEmailPage = () => {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const { verifyEmail, loading } = useVerifyEmail();
|
||||
|
||||
useEffect(() => {
|
||||
const handleVerifyEmail = async (token: string) => {
|
||||
try {
|
||||
await verifyEmail({ token });
|
||||
await queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
icon: <SealCheck size={16} weight="bold" />,
|
||||
title: "Your email address has been verified successfully.",
|
||||
});
|
||||
|
||||
navigate("/dashboard/resumes", { replace: true });
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to verify your email address",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) return;
|
||||
|
||||
handleVerifyEmail(token);
|
||||
}, [token, navigate, verifyEmail]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Verify your email address</h2>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
You should have received an email from <strong>Reactive Resume</strong> with a link to
|
||||
verify your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="info">
|
||||
<Info size={18} />
|
||||
|
||||
<AlertTitle>Please note that this step is completely optional.</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
We verify your email address only to ensure that we can send you a password reset link in
|
||||
case you forget your password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button asChild disabled={loading}>
|
||||
<Link to="/dashboard">
|
||||
Continue to Dashboard
|
||||
<ArrowRight className="ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
104
apps/client/src/pages/auth/verify-otp/page.tsx
Normal file
104
apps/client/src/pages/auth/verify-otp/page.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Warning } from "@phosphor-icons/react";
|
||||
import { twoFactorSchema } from "@reactive-resume/dto";
|
||||
import { usePasswordToggle } from "@reactive-resume/hooks";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { useVerifyOtp } from "@/client/services/auth";
|
||||
|
||||
type FormValues = z.infer<typeof twoFactorSchema>;
|
||||
|
||||
export const VerifyOtpPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { verifyOtp, loading } = useVerifyOtp();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
usePasswordToggle(formRef);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(twoFactorSchema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
await verifyOtp(data);
|
||||
|
||||
navigate("/dashboard");
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while trying to sign in",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Two Step Verification</h2>
|
||||
<h6>
|
||||
<span className="opacity-75">
|
||||
Enter the one-time password provided by your authenticator app below.
|
||||
</span>
|
||||
<Button asChild variant="link" className="px-1.5">
|
||||
<Link to="/auth/backup-otp">
|
||||
Lost your device? <ArrowRight className="ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="flex flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name="code"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>One-Time Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
apps/client/src/pages/builder/_components/header.tsx
Normal file
59
apps/client/src/pages/builder/_components/header.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { HouseSimple, SidebarSimple } from "@phosphor-icons/react";
|
||||
import { useBreakpoint } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useBuilderStore } from "@/client/stores/builder";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
export const BuilderHeader = () => {
|
||||
const { isDesktop } = useBreakpoint();
|
||||
const defaultPanelSize = isDesktop ? 25 : 0;
|
||||
|
||||
const toggle = useBuilderStore((state) => state.toggle);
|
||||
const title = useResumeStore((state) => state.resume.title);
|
||||
const isDragging = useBuilderStore(
|
||||
(state) => state.panel.left.isDragging || state.panel.right.isDragging,
|
||||
);
|
||||
const leftPanelSize = useBuilderStore(
|
||||
(state) => state.panel.left.ref?.getSize() ?? defaultPanelSize,
|
||||
);
|
||||
const rightPanelSize = useBuilderStore(
|
||||
(state) => state.panel.right.ref?.getSize() ?? defaultPanelSize,
|
||||
);
|
||||
|
||||
const onToggle = (side: "left" | "right") => toggle(side);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ left: `${leftPanelSize}%`, right: `${rightPanelSize}%` }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-[100] h-16 bg-secondary-accent/50 backdrop-blur-lg lg:z-20",
|
||||
!isDragging && "transition-[left,right]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full items-center justify-between px-4">
|
||||
<Button size="icon" variant="ghost" onClick={() => onToggle("left")}>
|
||||
<SidebarSimple />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-x-1">
|
||||
<Button asChild size="icon" variant="ghost">
|
||||
<Link to="/dashboard/resumes">
|
||||
<HouseSimple />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<span className="mr-2 text-xs opacity-40">{"/"}</span>
|
||||
|
||||
<h1 className="font-medium">{title}</h1>
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>
|
||||
<SidebarSimple className="-scale-x-100" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
apps/client/src/pages/builder/_components/toolbar.tsx
Normal file
167
apps/client/src/pages/builder/_components/toolbar.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
ArrowClockwise,
|
||||
ArrowCounterClockwise,
|
||||
CircleNotch,
|
||||
ClockClockwise,
|
||||
CubeFocus,
|
||||
DownloadSimple,
|
||||
Hash,
|
||||
LineSegment,
|
||||
LinkSimple,
|
||||
MagnifyingGlassMinus,
|
||||
MagnifyingGlassPlus,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button, Separator, Toggle, Tooltip } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { usePrintResume } from "@/client/services/resume";
|
||||
import { useBuilderStore } from "@/client/stores/builder";
|
||||
import { useResumeStore, useTemporalResumeStore } from "@/client/stores/resume";
|
||||
|
||||
export const BuilderToolbar = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const undo = useTemporalResumeStore((state) => state.undo);
|
||||
const redo = useTemporalResumeStore((state) => state.redo);
|
||||
const transformRef = useBuilderStore((state) => state.transform.ref);
|
||||
|
||||
const id = useResumeStore((state) => state.resume.id);
|
||||
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
|
||||
const pageOptions = useResumeStore((state) => state.resume.data.metadata.page.options);
|
||||
|
||||
const { printResume, loading } = usePrintResume();
|
||||
|
||||
const onPrint = async () => {
|
||||
const { url } = await printResume({ id });
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
const win = window.open(url, "_blank");
|
||||
if (win) win.focus();
|
||||
};
|
||||
|
||||
openInNewTab(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, bottom: -64 }}
|
||||
whileHover={{ opacity: 1, bottom: 0 }}
|
||||
animate={{ opacity: 0.3, bottom: -28 }}
|
||||
className="fixed inset-x-0 mx-auto pb-4 pt-6 text-center"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center rounded-full bg-background px-4 shadow-xl">
|
||||
{/* Undo */}
|
||||
<Tooltip content="Undo">
|
||||
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => undo()}>
|
||||
<ArrowCounterClockwise />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Redo */}
|
||||
<Tooltip content="Redo">
|
||||
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => redo()}>
|
||||
<ArrowClockwise />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-9" />
|
||||
|
||||
{/* Zoom In */}
|
||||
<Tooltip content="Zoom In">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => transformRef?.zoomIn(0.2)}
|
||||
>
|
||||
<MagnifyingGlassPlus />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Out */}
|
||||
<Tooltip content="Zoom Out">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => transformRef?.zoomOut(0.2)}
|
||||
>
|
||||
<MagnifyingGlassMinus />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Reset Zoom">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => transformRef?.resetTransform()}
|
||||
>
|
||||
<ClockClockwise />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Center Artboard */}
|
||||
<Tooltip content="Center Artboard">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={() => transformRef?.centerView()}
|
||||
>
|
||||
<CubeFocus />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-9" />
|
||||
|
||||
{/* Toggle Page Break Line */}
|
||||
<Tooltip content="Toggle Page Break Line">
|
||||
<Toggle
|
||||
className="rounded-none"
|
||||
pressed={pageOptions.breakLine}
|
||||
onPressedChange={(pressed) => {
|
||||
setValue("metadata.page.options.breakLine", pressed);
|
||||
}}
|
||||
>
|
||||
<LineSegment />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
|
||||
{/* Toggle Page Numbers */}
|
||||
<Tooltip content="Toggle Page Numbers">
|
||||
<Toggle
|
||||
className="rounded-none"
|
||||
pressed={pageOptions.pageNumbers}
|
||||
onPressedChange={(pressed) => {
|
||||
setValue("metadata.page.options.pageNumbers", pressed);
|
||||
}}
|
||||
>
|
||||
<Hash />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-9" />
|
||||
|
||||
{/* Copy Link to Resume */}
|
||||
<Tooltip content="Copy Link to Resume">
|
||||
<Button size="icon" variant="ghost" className="rounded-none" disabled={!isPublic}>
|
||||
<LinkSimple />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download PDF */}
|
||||
<Tooltip content="Download PDF">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
onClick={onPrint}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircleNotch className="animate-spin" /> : <DownloadSimple />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
100
apps/client/src/pages/builder/layout.tsx
Normal file
100
apps/client/src/pages/builder/layout.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useBreakpoint } from "@reactive-resume/hooks";
|
||||
import { Panel, PanelGroup, PanelResizeHandle, Sheet, SheetContent } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { useBuilderStore } from "@/client/stores/builder";
|
||||
|
||||
import { BuilderHeader } from "./_components/header";
|
||||
import { BuilderToolbar } from "./_components/toolbar";
|
||||
import { LeftSidebar } from "./sidebars/left";
|
||||
import { RightSidebar } from "./sidebars/right";
|
||||
|
||||
const OutletSlot = () => (
|
||||
<>
|
||||
<BuilderHeader />
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<BuilderToolbar />
|
||||
</>
|
||||
);
|
||||
|
||||
export const BuilderLayout = () => {
|
||||
const { isDesktop } = useBreakpoint();
|
||||
|
||||
const panel = useBuilderStore((state) => state.panel);
|
||||
const sheet = useBuilderStore((state) => state.sheet);
|
||||
|
||||
const onOpenAutoFocus = (event: Event) => event.preventDefault();
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
collapsible
|
||||
minSize={20}
|
||||
maxSize={35}
|
||||
defaultSize={28}
|
||||
ref={panel.left.setRef}
|
||||
className={cn("z-10 bg-background", !panel.left.isDragging && "transition-[flex]")}
|
||||
>
|
||||
<LeftSidebar />
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
isDragging={panel.left.isDragging}
|
||||
onDragging={panel.left.setDragging}
|
||||
/>
|
||||
<Panel>
|
||||
<OutletSlot />
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
isDragging={panel.right.isDragging}
|
||||
onDragging={panel.right.setDragging}
|
||||
/>
|
||||
<Panel
|
||||
collapsible
|
||||
minSize={20}
|
||||
maxSize={35}
|
||||
defaultSize={28}
|
||||
ref={panel.right.setRef}
|
||||
className={cn("z-10 bg-background", !panel.right.isDragging && "transition-[flex]")}
|
||||
>
|
||||
<RightSidebar />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Sheet open={sheet.left.open} onOpenChange={sheet.left.setOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="p-0 sm:max-w-xl"
|
||||
showClose={false}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
>
|
||||
<LeftSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<OutletSlot />
|
||||
|
||||
<Sheet open={sheet.right.open} onOpenChange={sheet.right.setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="p-0 sm:max-w-xl"
|
||||
showClose={false}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
>
|
||||
<RightSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
98
apps/client/src/pages/builder/page.tsx
Normal file
98
apps/client/src/pages/builder/page.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
import {
|
||||
Artboard,
|
||||
PageBreakLine,
|
||||
PageGrid,
|
||||
PageNumber,
|
||||
PageWrapper,
|
||||
Rhyhorn,
|
||||
} from "@reactive-resume/templates";
|
||||
import { pageSizeMap } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useMemo } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { LoaderFunction, redirect } from "react-router-dom";
|
||||
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { findResumeById } from "@/client/services/resume";
|
||||
import { useBuilderStore } from "@/client/stores/builder";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
export const BuilderPage = () => {
|
||||
const title = useResumeStore((state) => state.resume.title);
|
||||
const resume = useResumeStore((state) => state.resume.data);
|
||||
const setTransformRef = useBuilderStore((state) => state.transform.setRef);
|
||||
|
||||
const { pageHeight, showBreakLine, showPageNumbers } = useMemo(() => {
|
||||
const { format, options } = resume.metadata.page;
|
||||
|
||||
return {
|
||||
pageHeight: pageSizeMap[format].height,
|
||||
showBreakLine: options.breakLine,
|
||||
showPageNumbers: options.pageNumbers,
|
||||
};
|
||||
}, [resume.metadata.page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{title} - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
minScale={0.4}
|
||||
initialScale={1}
|
||||
limitToBounds={false}
|
||||
velocityAnimation={{ disabled: true }}
|
||||
ref={(ref: ReactZoomPanPinchRef) => setTransformRef(ref)}
|
||||
>
|
||||
<TransformComponent wrapperClass="!w-screen !h-screen">
|
||||
<PageGrid $offset={32}>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{resume.metadata.layout.map((columns, pageIndex) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={pageIndex}
|
||||
initial={{ opacity: 0, x: -100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
>
|
||||
<Artboard resume={resume}>
|
||||
<PageWrapper>
|
||||
{showPageNumbers && <PageNumber>Page {pageIndex + 1}</PageNumber>}
|
||||
|
||||
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||
|
||||
{showBreakLine && <PageBreakLine $pageHeight={pageHeight} />}
|
||||
</PageWrapper>
|
||||
</Artboard>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</PageGrid>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const builderLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
|
||||
try {
|
||||
const id = params.id as string;
|
||||
|
||||
const resume = await queryClient.fetchQuery({
|
||||
queryKey: ["resume", { id }],
|
||||
queryFn: () => findResumeById({ id }),
|
||||
});
|
||||
|
||||
useResumeStore.setState({ resume });
|
||||
useResumeStore.temporal.getState().clear();
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
};
|
||||
112
apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx
Normal file
112
apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { awardSchema, defaultAward } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = awardSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const AwardsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultAward,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="awards" form={form} defaultValues={defaultAward}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="3rd Runner Up" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="awarder"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Awarder</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="TechCrunch Disrupt SF" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Aug 2019" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://techcrunch.com/events/disrupt-sf-2019" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,112 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { certificationSchema, defaultCertification } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = certificationSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const CertificationsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultCertification,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="certifications" form={form} defaultValues={defaultCertification}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Web Developer Bootcamp" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="issuer"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Issuer</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Udemy" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Aug 2019" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://udemy.com/certificate/UC-..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,195 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { CustomSection, customSectionSchema, defaultCustomSection } from "@reactive-resume/schema";
|
||||
import {
|
||||
Badge,
|
||||
BadgeInput,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
Slider,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
import { DialogName, useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = customSectionSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const CustomSectionDialog = () => {
|
||||
const { payload } = useDialog<CustomSection>("custom");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultCustomSection,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues>
|
||||
form={form}
|
||||
id={payload.id as DialogName}
|
||||
defaultValues={defaultCustomSection}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="level"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Level</FormLabel>
|
||||
<FormControl className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Slider
|
||||
{...field}
|
||||
min={0}
|
||||
max={5}
|
||||
value={[field.value]}
|
||||
orientation="horizontal"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{field.value}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="keywords"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<FormItem>
|
||||
<FormLabel>Keywords</FormLabel>
|
||||
<FormControl>
|
||||
<BadgeInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can add multiple keywords by separating them with a comma.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||
<AnimatePresence>
|
||||
{field.value.map((item, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={item}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
field.onChange(field.value.filter((v) => item !== v));
|
||||
}}
|
||||
>
|
||||
<span className="mr-1">{item}</span>
|
||||
<X size={12} weight="bold" />
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,140 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultEducation, educationSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = educationSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const EducationDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultEducation,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="education" form={form} defaultValues={defaultEducation}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="institution"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Institution</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Carnegie Mellon University" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="studyType"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Type of Study</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Bachelor's Degree" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="area"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Area of Study</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Computer Science" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="score"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Score</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="9.2 GPA" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Aug 2006 - Oct 2012" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://www.cmu.edu/" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,126 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultExperience, experienceSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = experienceSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ExperienceDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultExperience,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="experience" form={form} defaultValues={defaultExperience}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="company"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Company</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Alphabet Inc." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="position"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Chief Executive Officer" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Dec 2019 - Present" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="location"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Location</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="New York, NY" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://www.abc.xyz/" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,93 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { defaultInterest, interestSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
Badge,
|
||||
BadgeInput,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
|
||||
const formSchema = interestSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const InterestsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultInterest,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="interests" form={form} defaultValues={defaultInterest}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Video Games" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="keywords"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<FormItem>
|
||||
<FormLabel>Keywords</FormLabel>
|
||||
<FormControl>
|
||||
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can add multiple keywords by separating them with a comma.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||
<AnimatePresence>
|
||||
{field.value.map((item, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={item}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
field.onChange(field.value.filter((v) => item !== v));
|
||||
}}
|
||||
>
|
||||
<span className="mr-1">{item}</span>
|
||||
<X size={12} weight="bold" />
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultLanguage, languageSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Slider,
|
||||
} from "@reactive-resume/ui";
|
||||
import { getCEFRLevel } from "@reactive-resume/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
|
||||
const formSchema = languageSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const LanguagesDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultLanguage,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="languages" form={form} defaultValues={defaultLanguage}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="German" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="fluency"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Fluency</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Native Speaker" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="fluencyLevel"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Fluency (CEFR)</FormLabel>
|
||||
<FormControl className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Slider
|
||||
{...field}
|
||||
min={1}
|
||||
max={6}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{getCEFRLevel(field.value)}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
112
apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx
Normal file
112
apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultProfile, profileSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = profileSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProfilesDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultProfile,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="profiles" form={form} defaultValues={defaultProfile}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="network"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Network</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="LinkedIn" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="johndoe" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://linkedin.com/in/johndoe" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="icon"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel htmlFor="iconSlug">Icon</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Avatar className="h-8 w-8 bg-white">
|
||||
{field.value && (
|
||||
<AvatarImage
|
||||
className="p-1.5"
|
||||
src={`https://cdn.simpleicons.org/${field.value}`}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Input {...field} id="iconSlug" placeholder="linkedin" />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription className="ml-10">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://simpleicons.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="font-medium"
|
||||
>
|
||||
Simple Icons
|
||||
</a>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
160
apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx
Normal file
160
apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { defaultProject, projectSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
Badge,
|
||||
BadgeInput,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = projectSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProjectsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultProject,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="projects" form={form} defaultValues={defaultProject}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Reactive Resume" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Open Source Resume Builder" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Sep 2018 - Present" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://rxresu.me" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="keywords"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<FormItem>
|
||||
<FormLabel>Keywords</FormLabel>
|
||||
<FormControl>
|
||||
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can add multiple keywords by separating them with a comma.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||
<AnimatePresence>
|
||||
{field.value.map((item, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={item}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
field.onChange(field.value.filter((v) => item !== v));
|
||||
}}
|
||||
>
|
||||
<span className="mr-1">{item}</span>
|
||||
<X size={12} weight="bold" />
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,112 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultPublication, publicationSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = publicationSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const PublicationsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultPublication,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="publications" form={form} defaultValues={defaultPublication}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="The Great Gatsby" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="publisher"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Publisher</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Charles Scribner's Sons" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Release Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="April 10, 1925" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://books.google.com/..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,98 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultReference, referenceSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = referenceSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ReferencesDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultReference,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="references" form={form} defaultValues={defaultReference}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Cosmo Kramer" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Neighbour" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://linkedin.com/in/cosmo.kramer" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
133
apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx
Normal file
133
apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { defaultSkill, skillSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
Badge,
|
||||
BadgeInput,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Slider,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
|
||||
const formSchema = skillSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const SkillsDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultSkill,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="skills" form={form} defaultValues={defaultSkill}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Content Management" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Advanced" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="level"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Level</FormLabel>
|
||||
<FormControl className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Slider
|
||||
{...field}
|
||||
min={1}
|
||||
max={5}
|
||||
value={[field.value]}
|
||||
orientation="horizontal"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{field.value}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="keywords"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<FormItem>
|
||||
<FormLabel>Keywords</FormLabel>
|
||||
<FormControl>
|
||||
<BadgeInput {...field} placeholder="WordPress, Joomla, Webflow etc." />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can add multiple keywords by separating them with a comma.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||
<AnimatePresence>
|
||||
{field.value.map((item, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={item}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
>
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
field.onChange(field.value.filter((v) => item !== v));
|
||||
}}
|
||||
>
|
||||
<span className="mr-1">{item}</span>
|
||||
<X size={12} weight="bold" />
|
||||
</Badge>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,126 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { defaultVolunteer, volunteerSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
RichInput,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
import { URLInput } from "../sections/shared/url-input";
|
||||
|
||||
const formSchema = volunteerSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const VolunteerDialog = () => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: defaultVolunteer,
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="volunteer" form={form} defaultValues={defaultVolunteer}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="organization"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Amnesty International" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="position"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Recruiter" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="date"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Dec 2016 - Aug 2017" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="location"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Location</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="New York, NY" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} placeholder="https://www.amnesty.org/" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="summary"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 sm:col-span-2">
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput
|
||||
{...field}
|
||||
content={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SectionDialog>
|
||||
);
|
||||
};
|
||||
193
apps/client/src/pages/builder/sidebars/left/index.tsx
Normal file
193
apps/client/src/pages/builder/sidebars/left/index.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { Plus, PlusCircle } from "@phosphor-icons/react";
|
||||
import {
|
||||
Award,
|
||||
Certification,
|
||||
CustomSectionItem,
|
||||
Education,
|
||||
Experience,
|
||||
Interest,
|
||||
Language,
|
||||
Profile,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
Skill,
|
||||
Volunteer,
|
||||
} from "@reactive-resume/schema";
|
||||
import { Button, ScrollArea, Separator } from "@reactive-resume/ui";
|
||||
import { getCEFRLevel } from "@reactive-resume/utils";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Icon } from "@/client/components/icon";
|
||||
import { UserAvatar } from "@/client/components/user-avatar";
|
||||
import { UserOptions } from "@/client/components/user-options";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { BasicsSection } from "./sections/basics";
|
||||
import { SectionBase } from "./sections/shared/section-base";
|
||||
import { SectionIcon } from "./sections/shared/section-icon";
|
||||
import { SummarySection } from "./sections/summary";
|
||||
|
||||
export const LeftSidebar = () => {
|
||||
const containterRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const addSection = useResumeStore((state) => state.addSection);
|
||||
const customSections = useResumeStore((state) => state.resume.data.sections.custom);
|
||||
|
||||
const scrollIntoView = (selector: string) => {
|
||||
const section = containterRef.current?.querySelector(selector);
|
||||
section?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
|
||||
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8 rounded-full">
|
||||
<Link to="/dashboard">
|
||||
<Icon size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<SectionIcon id="basics" name="Basics" onClick={() => scrollIntoView("#basics")} />
|
||||
<SectionIcon id="summary" onClick={() => scrollIntoView("#summary")} />
|
||||
<SectionIcon id="profiles" onClick={() => scrollIntoView("#profiles")} />
|
||||
<SectionIcon id="experience" onClick={() => scrollIntoView("#experience")} />
|
||||
<SectionIcon id="education" onClick={() => scrollIntoView("#education")} />
|
||||
<SectionIcon id="awards" onClick={() => scrollIntoView("#awards")} />
|
||||
<SectionIcon id="certifications" onClick={() => scrollIntoView("#certifications")} />
|
||||
<SectionIcon id="interests" onClick={() => scrollIntoView("#interests")} />
|
||||
<SectionIcon id="languages" onClick={() => scrollIntoView("#languages")} />
|
||||
<SectionIcon id="volunteer" onClick={() => scrollIntoView("#volunteer")} />
|
||||
<SectionIcon id="projects" onClick={() => scrollIntoView("#projects")} />
|
||||
<SectionIcon id="publications" onClick={() => scrollIntoView("#publications")} />
|
||||
<SectionIcon id="skills" onClick={() => scrollIntoView("#skills")} />
|
||||
<SectionIcon id="references" onClick={() => scrollIntoView("#references")} />
|
||||
|
||||
<SectionIcon
|
||||
id="custom"
|
||||
variant="outline"
|
||||
name="Add a new section"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => {
|
||||
addSection();
|
||||
scrollIntoView("& > section:last-of-type");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserOptions>
|
||||
<Button size="icon" variant="ghost" className="rounded-full">
|
||||
<UserAvatar size={28} />
|
||||
</Button>
|
||||
</UserOptions>
|
||||
</div>
|
||||
|
||||
<ScrollArea orientation="vertical" className="h-screen flex-1">
|
||||
<div ref={containterRef} className="grid gap-y-6 p-6 @container/left">
|
||||
<BasicsSection />
|
||||
<Separator />
|
||||
<SummarySection />
|
||||
<Separator />
|
||||
<SectionBase<Profile>
|
||||
id="profiles"
|
||||
title={(item) => item.network}
|
||||
description={(item) => item.username}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Experience>
|
||||
id="experience"
|
||||
title={(item) => item.company}
|
||||
description={(item) => item.position}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Education>
|
||||
id="education"
|
||||
title={(item) => item.institution}
|
||||
description={(item) => item.area}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Award>
|
||||
id="awards"
|
||||
title={(item) => item.title}
|
||||
description={(item) => item.awarder}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Certification>
|
||||
id="certifications"
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.issuer}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Interest>
|
||||
id="interests"
|
||||
title={(item) => item.name}
|
||||
description={(item) => {
|
||||
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Language>
|
||||
id="languages"
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.fluency || getCEFRLevel(item.fluencyLevel)}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Volunteer>
|
||||
id="volunteer"
|
||||
title={(item) => item.organization}
|
||||
description={(item) => item.position}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Project>
|
||||
id="projects"
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.description}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Publication>
|
||||
id="publications"
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.publisher}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Skill>
|
||||
id="skills"
|
||||
title={(item) => item.name}
|
||||
description={(item) => {
|
||||
if (item.description) return item.description;
|
||||
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SectionBase<Reference>
|
||||
id="references"
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.description}
|
||||
/>
|
||||
|
||||
{/* Custom Sections */}
|
||||
{Object.values(customSections).map((section) => (
|
||||
<Fragment key={section.id}>
|
||||
<Separator />
|
||||
|
||||
<SectionBase<CustomSectionItem>
|
||||
id={`custom.${section.id}`}
|
||||
title={(item) => item.name}
|
||||
description={(item) => item.description}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button size="lg" variant="outline" onClick={addSection}>
|
||||
<PlusCircle />
|
||||
<span className="ml-2">Add a new section</span>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { basicsSchema } from "@reactive-resume/schema";
|
||||
import { Input, Label } from "@reactive-resume/ui";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { CustomFieldsSection } from "./custom/section";
|
||||
import { PictureSection } from "./picture/section";
|
||||
import { getSectionIcon } from "./shared/section-icon";
|
||||
import { URLInput } from "./shared/url-input";
|
||||
|
||||
export const BasicsSection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<section id="basics" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("basics")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Basics</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<PictureSection />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="basics.name">Full Name</Label>
|
||||
<Input
|
||||
id="basics.name"
|
||||
placeholder="John Doe"
|
||||
value={basics.name}
|
||||
hasError={!basicsSchema.pick({ name: true }).safeParse({ name: basics.name }).success}
|
||||
onChange={(event) => setValue("basics.name", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="basics.headline">Headline</Label>
|
||||
<Input
|
||||
id="basics.headline"
|
||||
placeholder="Highly Creative Frontend Web Developer"
|
||||
value={basics.headline}
|
||||
onChange={(event) => setValue("basics.headline", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="basics.email">Email Address</Label>
|
||||
<Input
|
||||
id="basics.email"
|
||||
placeholder="john.doe@example.com"
|
||||
value={basics.email}
|
||||
hasError={
|
||||
!basicsSchema.pick({ email: true }).safeParse({ email: basics.email }).success
|
||||
}
|
||||
onChange={(event) => setValue("basics.email", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="basics.url">Website</Label>
|
||||
<URLInput
|
||||
id="basics.url"
|
||||
value={basics.url}
|
||||
placeholder="https://example.com"
|
||||
onChange={(value) => setValue("basics.url", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="basics.phone">Phone Number</Label>
|
||||
<Input
|
||||
id="basics.phone"
|
||||
placeholder="+1 (123) 4567 7890"
|
||||
value={basics.phone}
|
||||
onChange={(event) => setValue("basics.phone", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="basics.location">Location</Label>
|
||||
<Input
|
||||
id="basics.location"
|
||||
placeholder="105 Cedarhurst Ave, Cedarhurst, NY 11516"
|
||||
value={basics.location}
|
||||
onChange={(event) => setValue("basics.location", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomFieldsSection className="col-span-2" />
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,124 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { DotsSixVertical, Plus, X } from "@phosphor-icons/react";
|
||||
import { CustomField as ICustomField } from "@reactive-resume/schema";
|
||||
import { Button, Input } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, Reorder, useDragControls } from "framer-motion";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
type CustomFieldProps = {
|
||||
field: ICustomField;
|
||||
onChange: (field: ICustomField) => void;
|
||||
onRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) => {
|
||||
const controls = useDragControls();
|
||||
|
||||
const handleChange = (key: "name" | "value", value: string) =>
|
||||
onChange({ ...field, [key]: value });
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
>
|
||||
<div className="flex items-end justify-between space-x-4">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onPointerDown={(event) => controls.start(event)}
|
||||
>
|
||||
<DotsSixVertical />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="!ml-2"
|
||||
value={field.name}
|
||||
onChange={(event) => handleChange("name", event.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={field.value}
|
||||
onChange={(event) => handleChange("value", event.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!ml-2 shrink-0"
|
||||
onClick={() => onRemove(field.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CustomFieldsSection = ({ className }: Props) => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const customFields = useResumeStore((state) => state.resume.data.basics.customFields);
|
||||
|
||||
const onAddCustomField = () => {
|
||||
setValue("basics.customFields", [...customFields, { id: createId(), name: "", value: "" }]);
|
||||
};
|
||||
|
||||
const onChangeCustomField = (field: ICustomField) => {
|
||||
const index = customFields.findIndex((item) => item.id === field.id);
|
||||
const newCustomFields = JSON.parse(JSON.stringify(customFields)) as ICustomField[];
|
||||
newCustomFields[index] = field;
|
||||
|
||||
setValue("basics.customFields", newCustomFields);
|
||||
};
|
||||
|
||||
const onReorderCustomFields = (values: ICustomField[]) => {
|
||||
setValue("basics.customFields", values);
|
||||
};
|
||||
|
||||
const onRemoveCustomField = (id: string) => {
|
||||
setValue(
|
||||
"basics.customFields",
|
||||
customFields.filter((field) => field.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<AnimatePresence>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
className="space-y-4"
|
||||
values={customFields}
|
||||
onReorder={onReorderCustomFields}
|
||||
>
|
||||
{customFields.map((field) => (
|
||||
<CustomField
|
||||
field={field}
|
||||
key={field.id}
|
||||
onChange={onChangeCustomField}
|
||||
onRemove={onRemoveCustomField}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</AnimatePresence>
|
||||
|
||||
<Button variant="link" onClick={onAddCustomField}>
|
||||
<Plus className="mr-2" />
|
||||
<span>Add a custom field</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,219 @@
|
||||
import {
|
||||
AspectRatio,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
Tooltip,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
// Aspect Ratio Helpers
|
||||
const stringToRatioMap = {
|
||||
square: 1,
|
||||
portrait: 0.75,
|
||||
horizontal: 1.33,
|
||||
} as const;
|
||||
|
||||
const ratioToStringMap = {
|
||||
"1": "square",
|
||||
"0.75": "portrait",
|
||||
"1.33": "horizontal",
|
||||
} as const;
|
||||
|
||||
type AspectRatio = keyof typeof stringToRatioMap;
|
||||
|
||||
// Border Radius Helpers
|
||||
const stringToBorderRadiusMap = {
|
||||
square: 0,
|
||||
rounded: 6,
|
||||
circle: 9999,
|
||||
};
|
||||
|
||||
const borderRadiusToStringMap = {
|
||||
"0": "square",
|
||||
"6": "rounded",
|
||||
"9999": "circle",
|
||||
};
|
||||
|
||||
type BorderRadius = keyof typeof stringToBorderRadiusMap;
|
||||
|
||||
export const PictureOptions = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const picture = useResumeStore((state) => state.resume.data.basics.picture);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
const ratio = picture.aspectRatio?.toString() as keyof typeof ratioToStringMap;
|
||||
return ratioToStringMap[ratio];
|
||||
}, [picture.aspectRatio]);
|
||||
|
||||
const onAspectRatioChange = (value: AspectRatio) => {
|
||||
if (!value) return;
|
||||
setValue("basics.picture.aspectRatio", stringToRatioMap[value]);
|
||||
};
|
||||
|
||||
const borderRadius = useMemo(() => {
|
||||
const radius = picture.borderRadius?.toString() as keyof typeof borderRadiusToStringMap;
|
||||
return borderRadiusToStringMap[radius];
|
||||
}, [picture.borderRadius]);
|
||||
|
||||
const onBorderRadiusChange = (value: BorderRadius) => {
|
||||
if (!value) return;
|
||||
setValue("basics.picture.borderRadius", stringToBorderRadiusMap[value]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-5">
|
||||
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||
<Label htmlFor="picture.size" className="col-span-1">
|
||||
Size (in px)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="picture.size"
|
||||
placeholder="128"
|
||||
value={picture.size}
|
||||
className="col-span-2"
|
||||
onChange={(event) => {
|
||||
setValue("basics.picture.size", event.target.valueAsNumber);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||
<Label htmlFor="picture.aspectRatio" className="col-span-1">
|
||||
Aspect Ratio
|
||||
</Label>
|
||||
<div className="col-span-2 flex items-center justify-between">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={aspectRatio}
|
||||
onValueChange={onAspectRatioChange}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Tooltip content="Square">
|
||||
<ToggleGroupItem value="square">
|
||||
<div className="h-3 w-3 border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Horizontal">
|
||||
<ToggleGroupItem value="horizontal">
|
||||
<div className="h-2 w-3 border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Portrait">
|
||||
<ToggleGroupItem value="portrait">
|
||||
<div className="h-3 w-2 border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
</ToggleGroup>
|
||||
|
||||
<Input
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.05}
|
||||
type="number"
|
||||
className="w-[60px]"
|
||||
id="picture.aspectRatio"
|
||||
value={picture.aspectRatio}
|
||||
onChange={(event) => {
|
||||
setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||
<Label htmlFor="picture.borderRadius" className="col-span-1">
|
||||
Border Radius
|
||||
</Label>
|
||||
<div className="col-span-2 flex items-center justify-between">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={borderRadius}
|
||||
onValueChange={onBorderRadiusChange}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Tooltip content="Square">
|
||||
<ToggleGroupItem value="square">
|
||||
<div className="h-3 w-3 border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Rounded">
|
||||
<ToggleGroupItem value="rounded">
|
||||
<div className="h-3 w-3 rounded-sm border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Circle">
|
||||
<ToggleGroupItem value="circle">
|
||||
<div className="h-3 w-3 rounded-full border border-foreground" />
|
||||
</ToggleGroupItem>
|
||||
</Tooltip>
|
||||
</ToggleGroup>
|
||||
|
||||
<Input
|
||||
min={0}
|
||||
step={2}
|
||||
max={9999}
|
||||
type="number"
|
||||
className="w-[60px]"
|
||||
id="picture.borderRadius"
|
||||
value={picture.borderRadius}
|
||||
onChange={(event) => {
|
||||
setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-3 items-start gap-x-6">
|
||||
<div className="col-span-1">
|
||||
<Label>Effects</Label>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="picture.effects.hidden"
|
||||
checked={picture.effects.hidden}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("basics.picture.effects.hidden", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="picture.effects.hidden">Hidden</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="picture.effects.border"
|
||||
checked={picture.effects.border}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("basics.picture.effects.border", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="picture.effects.border">Border</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="picture.effects.grayscale"
|
||||
checked={picture.effects.grayscale}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("basics.picture.effects.grayscale", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="picture.effects.grayscale">Grayscale</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,102 @@
|
||||
import { Aperture, UploadSimple } from "@phosphor-icons/react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
buttonVariants,
|
||||
Input,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn, getInitials } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useUploadImage } from "@/client/services/storage";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { PictureOptions } from "./options";
|
||||
|
||||
export const PictureSection = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { uploadImage, loading } = useUploadImage();
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const name = useResumeStore((state) => state.resume.data.basics.name);
|
||||
const picture = useResumeStore((state) => state.resume.data.basics.picture);
|
||||
|
||||
const isValidUrl = useMemo(() => z.string().url().safeParse(picture.url).success, [picture.url]);
|
||||
|
||||
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
const file = event.target.files[0];
|
||||
const response = await uploadImage(file);
|
||||
const url = response.data;
|
||||
|
||||
setValue("basics.picture.url", url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Avatar className="h-14 w-14">
|
||||
{isValidUrl && <AvatarImage src={picture.url} />}
|
||||
<AvatarFallback className="text-lg font-bold">{getInitials(name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex w-full flex-col gap-y-1.5">
|
||||
<Label htmlFor="basics.picture.url">Picture</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
id="basics.picture.url"
|
||||
placeholder="https://..."
|
||||
value={picture.url}
|
||||
onChange={(event) => setValue("basics.picture.url", event.target.value)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{/* Show options button if picture exists */}
|
||||
{isValidUrl && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||
>
|
||||
<Aperture />
|
||||
</motion.button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px]">
|
||||
<PictureOptions />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Show upload button if picture doesn't exist, else show remove button to delete picture */}
|
||||
{!isValidUrl && (
|
||||
<>
|
||||
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
|
||||
|
||||
<motion.button
|
||||
disabled={loading}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||
>
|
||||
<UploadSimple />
|
||||
</motion.button>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,146 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import get from "lodash.get";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "./section-icon";
|
||||
import { SectionListItem } from "./section-list-item";
|
||||
import { SectionOptions } from "./section-options";
|
||||
|
||||
type Props<T extends SectionItem> = {
|
||||
id: SectionKey;
|
||||
title: (item: T) => string;
|
||||
description?: (item: T) => string | undefined;
|
||||
};
|
||||
|
||||
export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => {
|
||||
const { open } = useDialog(id);
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const section = useResumeStore((state) =>
|
||||
get(state.resume.data.sections, id),
|
||||
) as SectionWithItem<T>;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!section) return null;
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
const oldIndex = section.items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = section.items.findIndex((item) => item.id === over.id);
|
||||
|
||||
const sortedList = arrayMove(section.items as T[], oldIndex, newIndex);
|
||||
setValue(`sections.${id}.items`, sortedList);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = () => open("create", { id });
|
||||
const onUpdate = (item: T) => open("update", { id, item });
|
||||
const onDuplicate = (item: T) => open("duplicate", { id, item });
|
||||
const onDelete = (item: T) => open("delete", { id, item });
|
||||
|
||||
const onToggleVisibility = (index: number) => {
|
||||
const visible = get(section, `items[${index}].visible`, true);
|
||||
setValue(`sections.${id}.items[${index}].visible`, !visible);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
id={id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="grid gap-y-6"
|
||||
>
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon(id)}
|
||||
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SectionOptions id={id} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={cn("grid transition-opacity", !section.visible && "opacity-50")}>
|
||||
{section.items.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCreate}
|
||||
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="font-medium">Add New {section.name}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={onDragEnd}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToParentElement]}
|
||||
>
|
||||
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item, index) => (
|
||||
<SectionListItem
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
visible={item.visible}
|
||||
title={title(item as T)}
|
||||
description={description?.(item as T)}
|
||||
onUpdate={() => onUpdate(item as T)}
|
||||
onDelete={() => onDelete(item as T)}
|
||||
onDuplicate={() => onDuplicate(item as T)}
|
||||
onToggleVisibility={() => onToggleVisibility(index)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</main>
|
||||
|
||||
{section.items.length > 0 && (
|
||||
<footer className="flex items-center justify-end">
|
||||
<Button variant="outline" className="ml-auto gap-x-2" onClick={onCreate}>
|
||||
<Plus />
|
||||
<span>Add New {section.name}</span>
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</motion.section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,169 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopySimple, PencilSimple, Plus } from "@phosphor-icons/react";
|
||||
import { SectionItem, SectionWithItem } from "@reactive-resume/schema";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
} from "@reactive-resume/ui";
|
||||
import { produce } from "immer";
|
||||
import get from "lodash.get";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { DialogName, useDialog } from "@/client/stores/dialog";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
type Props<T extends SectionItem> = {
|
||||
id: DialogName;
|
||||
form: UseFormReturn<T>;
|
||||
defaultValues: T;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionDialog = <T extends SectionItem>({
|
||||
id,
|
||||
form,
|
||||
defaultValues,
|
||||
children,
|
||||
}: Props<T>) => {
|
||||
const { isOpen, mode, close, payload } = useDialog<T>(id);
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const section = useResumeStore((state) => {
|
||||
if (!id) return null;
|
||||
return get(state.resume.data.sections, id);
|
||||
}) as SectionWithItem<T> | null;
|
||||
const name = useMemo(() => section?.name ?? "", [section?.name]);
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const isUpdate = mode === "update";
|
||||
const isDelete = mode === "delete";
|
||||
const isDuplicate = mode === "duplicate";
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) onReset();
|
||||
}, [isOpen, payload]);
|
||||
|
||||
const onSubmit = async (values: T) => {
|
||||
if (!section) return;
|
||||
|
||||
if (isCreate || isDuplicate) {
|
||||
setValue(
|
||||
`sections.${id}.items`,
|
||||
produce(section.items, (draft: T[]): void => {
|
||||
draft.push({ ...values, id: createId() });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
setValue(
|
||||
`sections.${id}.items`,
|
||||
produce(section.items, (draft: T[]): void => {
|
||||
const index = draft.findIndex((item) => item.id === payload.item?.id);
|
||||
if (index === -1) return;
|
||||
draft[index] = values;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
setValue(
|
||||
`sections.${id}.items`,
|
||||
produce(section.items, (draft: T[]): void => {
|
||||
const index = draft.findIndex((item) => item.id === payload.item?.id);
|
||||
if (index === -1) return;
|
||||
draft.splice(index, 1);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
if (isCreate) form.reset({ ...defaultValues, id: createId() } as T);
|
||||
if (isUpdate) form.reset({ ...defaultValues, ...payload.item });
|
||||
if (isDuplicate) form.reset({ ...payload.item, id: createId() } as T);
|
||||
if (isDelete) form.reset({ ...defaultValues, ...payload.item });
|
||||
};
|
||||
|
||||
if (isDelete) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete this {name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action can be reverted by clicking on the undo button in the floating
|
||||
toolbar.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
{isCreate && <Plus />}
|
||||
{isUpdate && <PencilSimple />}
|
||||
{isDuplicate && <CopySimple />}
|
||||
<h2>
|
||||
{isCreate && `Create a new ${name}`}
|
||||
{isUpdate && `Update an existing ${name}`}
|
||||
{isDuplicate && `Duplicate an existing ${name}`}
|
||||
</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{children}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
{isCreate && "Create"}
|
||||
{isUpdate && "Save Changes"}
|
||||
{isDuplicate && "Duplicate"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Article,
|
||||
Books,
|
||||
Briefcase,
|
||||
Certificate,
|
||||
CompassTool,
|
||||
GameController,
|
||||
GraduationCap,
|
||||
HandHeart,
|
||||
IconProps,
|
||||
Medal,
|
||||
PuzzlePiece,
|
||||
ShareNetwork,
|
||||
Translate,
|
||||
User,
|
||||
Users,
|
||||
} from "@phosphor-icons/react";
|
||||
import { defaultSection, SectionKey, SectionWithItem } from "@reactive-resume/schema";
|
||||
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
|
||||
import get from "lodash.get";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
export const getSectionIcon = (id: SectionKey, props: IconProps = {}) => {
|
||||
switch (id) {
|
||||
// Left Sidebar
|
||||
case "basics":
|
||||
return <User size={18} {...props} />;
|
||||
case "summary":
|
||||
return <Article size={18} {...props} />;
|
||||
case "awards":
|
||||
return <Medal size={18} {...props} />;
|
||||
case "profiles":
|
||||
return <ShareNetwork size={18} {...props} />;
|
||||
case "experience":
|
||||
return <Briefcase size={18} {...props} />;
|
||||
case "education":
|
||||
return <GraduationCap size={18} {...props} />;
|
||||
case "certifications":
|
||||
return <Certificate size={18} {...props} />;
|
||||
case "interests":
|
||||
return <GameController size={18} {...props} />;
|
||||
case "languages":
|
||||
return <Translate size={18} {...props} />;
|
||||
case "volunteer":
|
||||
return <HandHeart size={18} {...props} />;
|
||||
case "projects":
|
||||
return <PuzzlePiece size={18} {...props} />;
|
||||
case "publications":
|
||||
return <Books size={18} {...props} />;
|
||||
case "skills":
|
||||
return <CompassTool size={18} {...props} />;
|
||||
case "references":
|
||||
return <Users size={18} {...props} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type SectionIconProps = ButtonProps & {
|
||||
id: SectionKey;
|
||||
name?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => {
|
||||
const section = useResumeStore((state) =>
|
||||
get(state.resume.data.sections, id, defaultSection),
|
||||
) as SectionWithItem;
|
||||
|
||||
return (
|
||||
<Tooltip side="right" content={name ?? section.name}>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
|
||||
{icon ?? getSectionIcon(id, { size: 14 })}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CopySimple, DotsSixVertical, PencilSimple, TrashSimple } from "@phosphor-icons/react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export type SectionListItemProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
visible?: boolean;
|
||||
description?: string;
|
||||
|
||||
// Callbacks
|
||||
onUpdate?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete?: () => void;
|
||||
onToggleVisibility?: () => void;
|
||||
};
|
||||
|
||||
export const SectionListItem = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
visible = true,
|
||||
onUpdate,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
}: SectionListItemProps) => {
|
||||
const { setNodeRef, transform, transition, attributes, listeners, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
zIndex: isDragging ? 100 : undefined,
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
ref={setNodeRef}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
className="border-x border-t bg-secondary/10 first-of-type:rounded-t last-of-type:rounded-b last-of-type:border-b"
|
||||
>
|
||||
<div style={style} className="flex transition-opacity">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"flex w-5 cursor-move items-center justify-center",
|
||||
!isDragging && "hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<DotsSixVertical size={12} />
|
||||
</div>
|
||||
|
||||
{/* List Item */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 cursor-context-menu p-4 hover:bg-secondary-accent",
|
||||
!visible && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<h4 className="font-medium leading-relaxed">{title}</h4>
|
||||
{description && <p className="text-xs leading-relaxed opacity-50">{description}</p>}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="left" sideOffset={-16}>
|
||||
<DropdownMenuCheckboxItem checked={visible} onCheckedChange={onToggleVisibility}>
|
||||
<span className="-ml-0.5">Visible</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem onClick={onUpdate}>
|
||||
<PencilSimple size={14} />
|
||||
<span className="ml-2">Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDuplicate}>
|
||||
<CopySimple size={14} />
|
||||
<span className="ml-2">Copy</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-error" onClick={onDelete}>
|
||||
<TrashSimple size={14} />
|
||||
<span className="ml-2">Remove</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,132 @@
|
||||
import {
|
||||
ArrowCounterClockwise,
|
||||
Broom,
|
||||
Columns,
|
||||
DotsThreeVertical,
|
||||
Eye,
|
||||
EyeSlash,
|
||||
PencilSimple,
|
||||
Plus,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { defaultSections, SectionKey, SectionWithItem } from "@reactive-resume/schema";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import get from "lodash.get";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
type Props = { id: SectionKey };
|
||||
|
||||
export const SectionOptions = ({ id }: Props) => {
|
||||
const { open } = useDialog(id);
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const removeSection = useResumeStore((state) => state.removeSection);
|
||||
|
||||
const originalName = get(defaultSections, `${id}.name`, "") as SectionWithItem;
|
||||
const section = useResumeStore((state) => get(state.resume.data.sections, id)) as SectionWithItem;
|
||||
|
||||
const hasItems = useMemo(() => "items" in section, [section]);
|
||||
const isCustomSection = useMemo(() => id.startsWith("custom"), [id]);
|
||||
|
||||
const onCreate = () => open("create", { id });
|
||||
const toggleVisibility = () => setValue(`sections.${id}.visible`, !section.visible);
|
||||
const onResetName = () => setValue(`sections.${id}.name`, originalName);
|
||||
const onChangeColumns = (value: string) => setValue(`sections.${id}.columns`, Number(value));
|
||||
const onResetItems = () => setValue(`sections.${id}.items`, []);
|
||||
const onRemove = () => removeSection(id);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<DotsThreeVertical weight="bold" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
{hasItems && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onCreate}>
|
||||
<Plus />
|
||||
<span className="ml-2">Add a new item</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={toggleVisibility}>
|
||||
{section.visible ? <Eye /> : <EyeSlash />}
|
||||
<span className="ml-2">{section.visible ? "Hide" : "Show"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PencilSimple />
|
||||
<span className="ml-2">Rename</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<div className="relative col-span-2">
|
||||
<Input
|
||||
id={`sections.${id}.name`}
|
||||
value={section.name}
|
||||
onChange={(event) => {
|
||||
setValue(`sections.${id}.name`, event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
onClick={onResetName}
|
||||
className="absolute inset-y-0 right-0"
|
||||
>
|
||||
<ArrowCounterClockwise />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Columns />
|
||||
<span className="ml-2">Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={`${section.columns}`} onValueChange={onChangeColumns}>
|
||||
<DropdownMenuRadioItem value="1">1 Column</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="2">2 Columns</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="3">3 Columns</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="4">4 Columns</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="5">5 Columns</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!hasItems} onClick={onResetItems}>
|
||||
<Broom />
|
||||
<span className="ml-2">Reset</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-error" disabled={!isCustomSection} onClick={onRemove}>
|
||||
<TrashSimple />
|
||||
<span className="ml-2">Remove</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import { Tag } from "@phosphor-icons/react";
|
||||
import { URL, urlSchema } from "@reactive-resume/schema";
|
||||
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
value: URL;
|
||||
placeholder?: string;
|
||||
onChange: (value: URL) => void;
|
||||
}
|
||||
|
||||
export const URLInput = forwardRef<HTMLInputElement, Props>(
|
||||
({ id, value, placeholder, onChange }, ref) => {
|
||||
const hasError = useMemo(() => urlSchema.safeParse(value).success === false, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-x-1">
|
||||
<Input
|
||||
id={id}
|
||||
ref={ref}
|
||||
value={value.href}
|
||||
className="flex-1"
|
||||
hasError={hasError}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange({ ...value, href: event.target.value })}
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<Tag />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-1.5">
|
||||
<Input
|
||||
value={value.label}
|
||||
placeholder="Label"
|
||||
onChange={(event) => onChange({ ...value, label: event.target.value })}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{hasError && <small className="opacity-75">URL must start with https://</small>}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,41 @@
|
||||
import { defaultSections } from "@reactive-resume/schema";
|
||||
import { RichInput } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { AiActions } from "@/client/components/ai-actions";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "./shared/section-icon";
|
||||
import { SectionOptions } from "./shared/section-options";
|
||||
|
||||
export const SummarySection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const section = useResumeStore(
|
||||
(state) => state.resume.data.sections.summary ?? defaultSections.summary,
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="summary" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("summary")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SectionOptions id="summary" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={cn(!section.visible && "opacity-50")}>
|
||||
<RichInput
|
||||
content={section.content}
|
||||
onChange={(value) => setValue("sections.summary.content", value)}
|
||||
footer={(editor) => (
|
||||
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||
)}
|
||||
/>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
72
apps/client/src/pages/builder/sidebars/right/index.tsx
Normal file
72
apps/client/src/pages/builder/sidebars/right/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { ScrollArea, Separator } from "@reactive-resume/ui";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
|
||||
import { ExportSection } from "./sections/export";
|
||||
import { InformationSection } from "./sections/information";
|
||||
import { LayoutSection } from "./sections/layout";
|
||||
import { PageSection } from "./sections/page";
|
||||
import { SharingSection } from "./sections/sharing";
|
||||
import { StatisticsSection } from "./sections/statistics";
|
||||
import { TemplateSection } from "./sections/template";
|
||||
import { ThemeSection } from "./sections/theme";
|
||||
import { TypographySection } from "./sections/typography";
|
||||
import { SectionIcon } from "./shared/section-icon";
|
||||
|
||||
export const RightSidebar = () => {
|
||||
const containterRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollIntoView = (selector: string) => {
|
||||
const section = containterRef.current?.querySelector(selector);
|
||||
section?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
|
||||
<ScrollArea orientation="vertical" className="h-screen flex-1">
|
||||
<div ref={containterRef} className="grid gap-y-6 p-6 @container/right">
|
||||
<TemplateSection />
|
||||
<Separator />
|
||||
<LayoutSection />
|
||||
<Separator />
|
||||
<TypographySection />
|
||||
<Separator />
|
||||
<ThemeSection />
|
||||
<Separator />
|
||||
<PageSection />
|
||||
<Separator />
|
||||
<SharingSection />
|
||||
<Separator />
|
||||
<StatisticsSection />
|
||||
<Separator />
|
||||
<ExportSection />
|
||||
<Separator />
|
||||
<InformationSection />
|
||||
<Separator />
|
||||
<Copyright className="text-center" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
|
||||
<div />
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<SectionIcon id="template" name="Template" onClick={() => scrollIntoView("#template")} />
|
||||
<SectionIcon id="layout" name="Layout" onClick={() => scrollIntoView("#layout")} />
|
||||
<SectionIcon
|
||||
id="typography"
|
||||
name="Typography"
|
||||
onClick={() => scrollIntoView("#typography")}
|
||||
/>
|
||||
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
|
||||
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
|
||||
<SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} />
|
||||
</div>
|
||||
|
||||
<ThemeSwitch size={14} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
import { CircleNotch, FileJs, FilePdf } from "@phosphor-icons/react";
|
||||
import { buttonVariants, Card, CardContent, CardDescription, CardTitle } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { usePrintResume } from "@/client/services/resume/print";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const ExportSection = () => {
|
||||
const { toast } = useToast();
|
||||
const { printResume, loading } = usePrintResume();
|
||||
|
||||
const onJsonExport = () => {
|
||||
const { resume } = useResumeStore.getState();
|
||||
const filename = `reactive_resume-${resume.id}.json`;
|
||||
const resumeJSON = JSON.stringify(resume.data, null, 2);
|
||||
|
||||
saveAs(new Blob([resumeJSON], { type: "application/json" }), filename);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "A JSON snapshot of your resume has been successfully exported.",
|
||||
});
|
||||
};
|
||||
|
||||
const onPdfExport = async () => {
|
||||
const { resume } = useResumeStore.getState();
|
||||
const { url } = await printResume({ id: resume.id });
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
const win = window.open(url, "_blank");
|
||||
if (win) win.focus();
|
||||
};
|
||||
|
||||
openInNewTab(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="export" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("export")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Export</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<Card
|
||||
onClick={onJsonExport}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
|
||||
)}
|
||||
>
|
||||
<FileJs size={22} />
|
||||
<CardContent className="flex-1">
|
||||
<CardTitle className="text-sm">JSON</CardTitle>
|
||||
<CardDescription className="font-normal">
|
||||
Download a JSON snapshot of your resume. This file can be used to import your resume
|
||||
in the future, or can even be shared with others to collaborate.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
onClick={onPdfExport}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
|
||||
loading && "pointer-events-none cursor-progress opacity-75",
|
||||
)}
|
||||
>
|
||||
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}
|
||||
|
||||
<CardContent className="flex-1">
|
||||
<CardTitle className="text-sm">PDF</CardTitle>
|
||||
<CardDescription className="font-normal">
|
||||
Download a PDF of your resume. This file can be used to print your resume, send it to
|
||||
recruiters, or upload on job portals.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,129 @@
|
||||
import { Book, EnvelopeSimpleOpen, GithubLogo, HandHeart } from "@phosphor-icons/react";
|
||||
import {
|
||||
buttonVariants,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
const DonateCard = () => (
|
||||
<Card className="space-y-4 bg-info text-info-foreground">
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>Support the app by donating what you can!</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>
|
||||
I built Reactive Resume mostly by myself during my spare time, with a lot of help from
|
||||
other great open-source contributors.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you like the app and want to support keeping it free forever, please donate whatever
|
||||
you can afford to give.
|
||||
</p>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href="https://github.com/sponsors/AmruthPillai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<HandHeart size={14} weight="bold" className="mr-2" />
|
||||
<span>Donate to Reactive Resume</span>
|
||||
</a>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const IssuesCard = () => (
|
||||
<Card className="space-y-4">
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>Found a bug, or have an idea for a new feature?</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>I'm sure the app is not perfect, but I'd like for it to be.</p>
|
||||
|
||||
<p>
|
||||
If you faced any issues while creating your resume, or have an idea that would help you
|
||||
and other users in creating your resume more easily, drop an issue on the repository or
|
||||
send me an email about it.
|
||||
</p>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="space-x-4">
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href="https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<GithubLogo size={14} weight="bold" className="mr-2" />
|
||||
<span>Raise an issue</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href="mailto:hello@amruthpillai.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<EnvelopeSimpleOpen size={14} weight="bold" className="mr-2" />
|
||||
<span>Send me a message</span>
|
||||
</a>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const DocumentationCard = () => (
|
||||
<Card className="space-y-4">
|
||||
<CardContent className="space-y-2">
|
||||
<CardTitle>Don't know where to begin? Hit the docs!</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>
|
||||
The community has spent a lot of time writing the documentation for Reactive Resume, and
|
||||
I'm sure it will help you get started with the app.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
There are also a lot of examples to help you get started, and features that you might not
|
||||
know about which could help you build your perfect resume.
|
||||
</p>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="space-x-4">
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href="https://docs.rxresu.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Book size={14} weight="bold" className="mr-2" />
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const InformationSection = () => {
|
||||
return (
|
||||
<section id="information" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("information")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Information</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<DonateCard />
|
||||
<DocumentationCard />
|
||||
<IssuesCard />
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
269
apps/client/src/pages/builder/sidebars/right/sections/layout.tsx
Normal file
269
apps/client/src/pages/builder/sidebars/right/sections/layout.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ArrowCounterClockwise, DotsSixVertical, Plus, TrashSimple } from "@phosphor-icons/react";
|
||||
import { defaultMetadata } from "@reactive-resume/schema";
|
||||
import { Button, Portal, Tooltip } from "@reactive-resume/ui";
|
||||
import {
|
||||
cn,
|
||||
LayoutLocator,
|
||||
moveItemInLayout,
|
||||
parseLayoutLocator,
|
||||
SortablePayload,
|
||||
} from "@reactive-resume/utils";
|
||||
import get from "lodash.get";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
type ColumnProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
const Column = ({ id, name, items }: ColumnProps) => {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<SortableContext id={id} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 w-3/4 rounded bg-secondary/50" />
|
||||
|
||||
<div className="relative z-10 p-3 pb-8">
|
||||
<p className="mb-3 text-xs font-bold">{name}</p>
|
||||
|
||||
<div ref={setNodeRef} className="space-y-3">
|
||||
{items.map((section) => (
|
||||
<SortableSection key={section} id={section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
|
||||
type SortableSectionProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const SortableSection = ({ id }: SortableSectionProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||
<Section id={id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
id: string;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
const Section = ({ id, isDragging = false }: SectionProps) => {
|
||||
const name = useResumeStore((state) =>
|
||||
get(state.resume.data.sections, `${id}.name`, id),
|
||||
) as string;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-grab rounded bg-primary p-2 text-primary-foreground transition-colors hover:bg-primary-accent",
|
||||
isDragging && "cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DotsSixVertical size={12} weight="bold" />
|
||||
<p className="flex-1 truncate text-xs font-medium">{name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LayoutSection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const layout = useResumeStore((state) => state.resume.data.metadata.layout);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const onDragStart = ({ active }: DragStartEvent) => {
|
||||
setActiveId(active.id as string);
|
||||
};
|
||||
|
||||
const onDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const onDragEvent = ({ active, over }: DragOverEvent | DragEndEvent) => {
|
||||
if (!over || !active.data.current) return;
|
||||
|
||||
const currentPayload = active.data.current.sortable as SortablePayload | null;
|
||||
const current = parseLayoutLocator(currentPayload);
|
||||
|
||||
if (active.id === over.id) return;
|
||||
|
||||
if (!over.data.current) {
|
||||
const [page, column] = (over.id as string).split(".").map(Number);
|
||||
const target = { page, column, section: 0 } as LayoutLocator;
|
||||
|
||||
const newLayout = moveItemInLayout(current, target, layout);
|
||||
setValue("metadata.layout", newLayout);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPayload = over.data.current.sortable as SortablePayload | null;
|
||||
const target = parseLayoutLocator(targetPayload);
|
||||
|
||||
const newLayout = moveItemInLayout(current, target, layout);
|
||||
setValue("metadata.layout", newLayout);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
onDragEvent(event);
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const onAddPage = () => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
|
||||
|
||||
layoutCopy.push([[], []]);
|
||||
|
||||
setValue("metadata.layout", layoutCopy);
|
||||
};
|
||||
|
||||
const onRemovePage = (page: number) => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
|
||||
|
||||
layoutCopy[0][0].push(...layoutCopy[page][0]); // Main
|
||||
layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar
|
||||
|
||||
layoutCopy.splice(page, 1);
|
||||
|
||||
setValue("metadata.layout", layoutCopy);
|
||||
};
|
||||
|
||||
const onResetLayout = () => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][];
|
||||
|
||||
// Loop through all pages and columns, and get any sections that start with "custom."
|
||||
// These should be appended to the first page of the new layout.
|
||||
const customSections: string[] = [];
|
||||
|
||||
layout.forEach((page) => {
|
||||
page.forEach((column) => {
|
||||
customSections.push(...column.filter((section) => section.startsWith("custom.")));
|
||||
});
|
||||
});
|
||||
|
||||
if (customSections.length > 0) layoutCopy[0][0].push(...customSections);
|
||||
|
||||
setValue("metadata.layout", layoutCopy);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="layout" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("layout")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Layout</h2>
|
||||
</div>
|
||||
|
||||
<Tooltip content="Reset Layout">
|
||||
<Button size="icon" variant="ghost" onClick={onResetLayout}>
|
||||
<ArrowCounterClockwise />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
{/* Pages */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
{layout.map((page, pageIndex) => {
|
||||
const mainIndex = `${pageIndex}.0`;
|
||||
const sidebarIndex = `${pageIndex}.1`;
|
||||
|
||||
const main = page[0];
|
||||
const sidebar = page[1];
|
||||
|
||||
return (
|
||||
<div key={pageIndex} className="rounded border p-3 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="mb-3 text-xs font-bold">Page {pageIndex + 1}</p>
|
||||
|
||||
{pageIndex !== 0 && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onRemovePage(pageIndex)}
|
||||
>
|
||||
<TrashSimple size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 items-start gap-x-4">
|
||||
<Column id={mainIndex} name="Main" items={main} />
|
||||
<Column id={sidebarIndex} name="Sidebar" items={sidebar} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Portal>
|
||||
<DragOverlay>{activeId && <Section id={activeId} isDragging />}</DragOverlay>
|
||||
</Portal>
|
||||
</DndContext>
|
||||
|
||||
<Button variant="outline" className="ml-auto" onClick={onAddPage}>
|
||||
<Plus />
|
||||
<span className="ml-2">Add New Page</span>
|
||||
</Button>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import {
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Slider,
|
||||
Switch,
|
||||
} from "@reactive-resume/ui";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const PageSection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const page = useResumeStore((state) => state.resume.data.metadata.page);
|
||||
|
||||
return (
|
||||
<section id="theme" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("page")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Page</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={page.format}
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.page.format", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a4">A4</SelectItem>
|
||||
<SelectItem value="letter">Letter</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Margin</Label>
|
||||
<div className="flex items-center gap-x-4 py-1">
|
||||
<Slider
|
||||
min={0}
|
||||
max={48}
|
||||
step={2}
|
||||
value={[page.margin]}
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.page.margin", value[0]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{page.margin}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Options</Label>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="metadata.page.options.breakLine"
|
||||
checked={page.options.breakLine}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.page.options.breakLine", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.page.options.breakLine">Show Break Line</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="metadata.page.options.pageNumbers"
|
||||
checked={page.options.pageNumbers}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.page.options.pageNumbers", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.page.options.pageNumbers">Show Page Numbers</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import { CopySimple } from "@phosphor-icons/react";
|
||||
import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useUser } from "@/client/services/user";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const SharingSection = () => {
|
||||
const { user } = useUser();
|
||||
const { toast } = useToast();
|
||||
const username = user?.username;
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const slug = useResumeStore((state) => state.resume.slug);
|
||||
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
|
||||
|
||||
// Constants
|
||||
const url = `${window.location.origin}/${username}/${slug}`;
|
||||
|
||||
const onCopy = async () => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "A link has been copied to your clipboard.",
|
||||
description:
|
||||
"Anyone with this link can view and download the resume. Share it on your profile or with recruiters.",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="sharing" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("sharing")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Sharing</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="visibility"
|
||||
checked={isPublic}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("visibility", checked ? "public" : "private");
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="visibility" className="space-y-1">
|
||||
<p>Public</p>
|
||||
<p className="text-xs opacity-60">
|
||||
Anyone with the link can view and download the resume.
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{isPublic && (
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-1.5"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<Label htmlFor="resume-url">URL</Label>
|
||||
|
||||
<div className="flex gap-x-1.5">
|
||||
<Input id="resume-url" readOnly value={url} className="flex-1" />
|
||||
|
||||
<Tooltip content="Copy to Clipboard">
|
||||
<Button size="icon" variant="ghost" onClick={onCopy}>
|
||||
<CopySimple />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import { Info } from "@phosphor-icons/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useResumeStatistics } from "@/client/services/resume";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const StatisticsSection = () => {
|
||||
const id = useResumeStore((state) => state.resume.id);
|
||||
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
|
||||
|
||||
const { statistics } = useResumeStatistics(id, isPublic);
|
||||
|
||||
return (
|
||||
<section id="statistics" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("statistics")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Statistics</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid grid-cols-2 gap-y-4">
|
||||
<AnimatePresence>
|
||||
{!isPublic && (
|
||||
<motion.div
|
||||
className="col-span-2"
|
||||
initial={{ opacity: 0, y: -50, filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, y: -50, filter: "blur(10px)" }}
|
||||
>
|
||||
<Alert variant="info">
|
||||
<Info size={18} />
|
||||
|
||||
<AlertTitle>Statistics are available only for public resumes.</AlertTitle>
|
||||
|
||||
<AlertDescription className="text-xs leading-relaxed">
|
||||
You can track the number of views your resume has received, or how many people
|
||||
have downloaded the resume by enabling public sharing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
|
||||
{statistics?.views ?? 0}
|
||||
</h3>
|
||||
<p className="opacity-75">Views</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
|
||||
{statistics?.downloads ?? 0}
|
||||
</h3>
|
||||
<p className="opacity-75">Downloads</p>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const TemplateSection = () => {
|
||||
// TODO: Import templates from @reactive-resume/templates
|
||||
const templateList = ["rhyhorn"];
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const currentTemplate = useResumeStore((state) => state.resume.data.metadata.template);
|
||||
|
||||
return (
|
||||
<section id="template" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("template")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Template</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid grid-cols-2 gap-y-4">
|
||||
{templateList.map((template) => (
|
||||
<Button
|
||||
key={template}
|
||||
variant="outline"
|
||||
disabled={template === currentTemplate}
|
||||
onClick={() => setValue("metadata.template", template)}
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm capitalize ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
|
||||
template === currentTemplate && "ring-1",
|
||||
)}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
133
apps/client/src/pages/builder/sidebars/right/sections/theme.tsx
Normal file
133
apps/client/src/pages/builder/sidebars/right/sections/theme.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { Input, Label, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
|
||||
import { colors } from "@/client/constants/colors";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const ThemeSection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const theme = useResumeStore((state) => state.resume.data.metadata.theme);
|
||||
|
||||
return (
|
||||
<section id="theme" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("theme")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Theme</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<div className="mb-2 grid grid-cols-6 flex-wrap justify-items-center gap-y-4 @xs/right:grid-cols-9">
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
onClick={() => {
|
||||
setValue("metadata.theme.primary", color);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ring-primary ring-offset-1 ring-offset-background transition-shadow hover:ring-1",
|
||||
theme.primary === color && "ring-1",
|
||||
)}
|
||||
>
|
||||
<div className="h-5 w-5 rounded-full" style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="theme.primary">Primary Color</Label>
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
|
||||
style={{ backgroundColor: theme.primary }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
|
||||
<HexColorPicker
|
||||
color={theme.primary}
|
||||
onChange={(color) => {
|
||||
setValue("metadata.theme.primary", color);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
id="theme.primary"
|
||||
value={theme.primary}
|
||||
className="pl-10"
|
||||
onChange={(event) => {
|
||||
setValue("metadata.theme.primary", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="theme.primary">Background Color</Label>
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
|
||||
style={{ backgroundColor: theme.background }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
|
||||
<HexColorPicker
|
||||
color={theme.background}
|
||||
onChange={(color) => {
|
||||
setValue("metadata.theme.background", color);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
id="theme.background"
|
||||
value={theme.background}
|
||||
className="pl-10"
|
||||
onChange={(event) => {
|
||||
setValue("metadata.theme.background", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="theme.primary">Text Color</Label>
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
|
||||
style={{ backgroundColor: theme.text }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
|
||||
<HexColorPicker
|
||||
color={theme.text}
|
||||
onChange={(color) => {
|
||||
setValue("metadata.theme.text", color);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
id="theme.text"
|
||||
value={theme.text}
|
||||
className="pl-10"
|
||||
onChange={(event) => {
|
||||
setValue("metadata.theme.text", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { fonts } from "@reactive-resume/utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import webfontloader from "webfontloader";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
const fontSuggestions = [
|
||||
"Open Sans",
|
||||
"Merriweather",
|
||||
"CMU Serif",
|
||||
"Playfair Display",
|
||||
"Lato",
|
||||
"Lora",
|
||||
"PT Sans",
|
||||
"PT Serif",
|
||||
"IBM Plex Sans",
|
||||
"IBM Plex Serif",
|
||||
];
|
||||
|
||||
const families: ComboboxOption[] = fonts.map((font) => ({
|
||||
value: font.family,
|
||||
label: font.family,
|
||||
}));
|
||||
|
||||
export const TypographySection = () => {
|
||||
const [subsets, setSubsets] = useState<ComboboxOption[]>([]);
|
||||
const [variants, setVariants] = useState<ComboboxOption[]>([]);
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
|
||||
|
||||
const loadFontSuggestions = useCallback(async () => {
|
||||
fontSuggestions.forEach((font) => {
|
||||
if (font === "CMU Serif") return;
|
||||
webfontloader.load({
|
||||
events: false,
|
||||
classes: false,
|
||||
google: { families: [font], text: font },
|
||||
});
|
||||
});
|
||||
}, [fontSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFontSuggestions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subsets = fonts.find((font) => font.family === typography.font.family)?.subsets ?? [];
|
||||
setSubsets(subsets.map((subset) => ({ value: subset, label: subset })));
|
||||
|
||||
const variants = fonts.find((font) => font.family === typography.font.family)?.variants ?? [];
|
||||
setVariants(variants.map((variant) => ({ value: variant, label: variant })));
|
||||
}, [typography.font.family]);
|
||||
|
||||
return (
|
||||
<section id="typography" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("typography")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Typography</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fontSuggestions.map((font) => (
|
||||
<Button
|
||||
key={font}
|
||||
variant="outline"
|
||||
style={{ fontFamily: font }}
|
||||
disabled={typography.font.family === font}
|
||||
onClick={() => {
|
||||
setValue("metadata.typography.font.family", font);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
|
||||
typography.font.family === font && "ring-1",
|
||||
)}
|
||||
>
|
||||
{font}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Font Family</Label>
|
||||
<Combobox
|
||||
options={families}
|
||||
value={typography.font.family}
|
||||
searchPlaceholder="Search for a font family"
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.typography.font.family", value);
|
||||
setValue("metadata.typography.font.subset", "latin");
|
||||
setValue("metadata.typography.font.variants", ["regular"]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Font Subset</Label>
|
||||
<Combobox
|
||||
options={subsets}
|
||||
value={typography.font.subset}
|
||||
searchPlaceholder="Search for a font subset"
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.typography.font.subset", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Font Variants</Label>
|
||||
<Combobox
|
||||
multiple
|
||||
options={variants}
|
||||
value={typography.font.variants}
|
||||
searchPlaceholder="Search for a font variant"
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.typography.font.variants", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Font Size</Label>
|
||||
<div className="flex items-center gap-x-4 py-1">
|
||||
<Slider
|
||||
min={12}
|
||||
max={18}
|
||||
step={1}
|
||||
value={[typography.font.size]}
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.typography.font.size", value[0]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{typography.font.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Line Height</Label>
|
||||
<div className="flex items-center gap-x-4 py-1">
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.25}
|
||||
value={[typography.lineHeight]}
|
||||
onValueChange={(value) => {
|
||||
setValue("metadata.typography.lineHeight", value[0]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-base font-bold">{typography.lineHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Options</Label>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="metadata.typography.underlineLinks"
|
||||
checked={typography.underlineLinks}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.typography.underlineLinks", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.typography.underlineLinks">Underline Links</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import {
|
||||
DiamondsFour,
|
||||
DownloadSimple,
|
||||
IconProps,
|
||||
Info,
|
||||
Layout,
|
||||
Palette,
|
||||
ReadCvLogo,
|
||||
ShareFat,
|
||||
TextT,
|
||||
TrendUp,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
|
||||
|
||||
export type MetadataKey =
|
||||
| "template"
|
||||
| "layout"
|
||||
| "typography"
|
||||
| "theme"
|
||||
| "page"
|
||||
| "sharing"
|
||||
| "statistics"
|
||||
| "export"
|
||||
| "information";
|
||||
|
||||
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
|
||||
switch (id) {
|
||||
// Left Sidebar
|
||||
case "template":
|
||||
return <DiamondsFour size={18} {...props} />;
|
||||
case "layout":
|
||||
return <Layout size={18} {...props} />;
|
||||
case "typography":
|
||||
return <TextT size={18} {...props} />;
|
||||
case "theme":
|
||||
return <Palette size={18} {...props} />;
|
||||
case "page":
|
||||
return <ReadCvLogo size={18} {...props} />;
|
||||
case "sharing":
|
||||
return <ShareFat size={18} {...props} />;
|
||||
case "statistics":
|
||||
return <TrendUp size={18} {...props} />;
|
||||
case "export":
|
||||
return <DownloadSimple size={18} {...props} />;
|
||||
case "information":
|
||||
return <Info size={18} {...props} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type SectionIconProps = ButtonProps & {
|
||||
id: MetadataKey;
|
||||
name: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => (
|
||||
<Tooltip side="left" content={name}>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
|
||||
{icon ?? getSectionIcon(id, { size: 14 })}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
129
apps/client/src/pages/dashboard/_components/sidebar.tsx
Normal file
129
apps/client/src/pages/dashboard/_components/sidebar.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { FadersHorizontal, ReadCvLogo } from "@phosphor-icons/react";
|
||||
import { Button, KeyboardShortcut, Separator } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { Icon } from "@/client/components/icon";
|
||||
import { UserAvatar } from "@/client/components/user-avatar";
|
||||
import { UserOptions } from "@/client/components/user-options";
|
||||
import { useUser } from "@/client/services/user";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ActiveIndicator = ({ className }: Props) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 animate-pulse rounded-full bg-info shadow-[0_0_12px] shadow-info",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
interface SidebarItem {
|
||||
path: string;
|
||||
name: string;
|
||||
shortcut?: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
path: "/dashboard/resumes",
|
||||
name: "Resumes",
|
||||
shortcut: "⇧R",
|
||||
icon: <ReadCvLogo />,
|
||||
},
|
||||
{
|
||||
path: "/dashboard/settings",
|
||||
name: "Settings",
|
||||
shortcut: "⇧S",
|
||||
icon: <FadersHorizontal />,
|
||||
},
|
||||
];
|
||||
|
||||
type SidebarItemProps = SidebarItem & {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SidebarItem = ({ path, name, shortcut, icon, onClick }: SidebarItemProps) => {
|
||||
const isActive = useLocation().pathname === path;
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-auto justify-start px-4 py-3",
|
||||
isActive && "pointer-events-none bg-secondary/50 text-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<Link to={path}>
|
||||
<div className="mr-3">{icon}</div>
|
||||
<span>{name}</span>
|
||||
{!isActive && <KeyboardShortcut className="ml-auto">{shortcut}</KeyboardShortcut>}
|
||||
{isActive && <ActiveIndicator className="ml-auto" />}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
setOpen?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const Sidebar = ({ setOpen }: SidebarProps) => {
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useKeyboardShortcut(["shift", "r"], () => {
|
||||
navigate("/dashboard/resumes");
|
||||
setOpen?.(false);
|
||||
});
|
||||
|
||||
useKeyboardShortcut(["shift", "s"], () => {
|
||||
navigate("/dashboard/settings");
|
||||
setOpen?.(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-y-4">
|
||||
<div className="ml-12 flex justify-center lg:ml-0">
|
||||
<Button asChild size="icon" variant="ghost" className="h-10 w-10 p-0">
|
||||
<Link to="/">
|
||||
<Icon size={24} className="mx-auto hidden lg:block" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
<div className="grid gap-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarItem {...item} key={item.path} onClick={() => setOpen?.(false)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
<UserOptions>
|
||||
<Button size="lg" variant="ghost" className="w-full justify-start px-3">
|
||||
<UserAvatar size={24} className="mr-3" />
|
||||
<span>{user?.name}</span>
|
||||
</Button>
|
||||
</UserOptions>
|
||||
|
||||
<Copyright className="ml-2" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
apps/client/src/pages/dashboard/layout.tsx
Normal file
49
apps/client/src/pages/dashboard/layout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { SidebarSimple } from "@phosphor-icons/react";
|
||||
import { Button, Sheet, SheetClose, SheetContent, SheetTrigger } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { Sidebar } from "./_components/sidebar";
|
||||
|
||||
export const DashboardLayout = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-50 flex items-center justify-between p-4 lg:hidden">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="bg-background">
|
||||
<SidebarSimple />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent showClose={false} side="left" className="focus-visible:outline-none">
|
||||
<SheetClose asChild className="absolute left-4 top-4">
|
||||
<Button size="icon" variant="ghost">
|
||||
<SidebarSimple />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
|
||||
<Sidebar setOpen={setOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: -320 }}
|
||||
animate={{ x: 0 }}
|
||||
className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[320px] lg:flex-col"
|
||||
>
|
||||
<div className="h-full rounded p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<main className="mx-6 my-4 lg:mx-8 lg:pl-[320px]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
315
apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx
Normal file
315
apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, DownloadSimple, Warning } from "@phosphor-icons/react";
|
||||
import {
|
||||
JsonResume,
|
||||
JsonResumeParser,
|
||||
LinkedIn,
|
||||
LinkedInParser,
|
||||
ReactiveResumeParser,
|
||||
ReactiveResumeV3,
|
||||
ReactiveResumeV3Parser,
|
||||
} from "@reactive-resume/parser";
|
||||
import { ResumeData } from "@reactive-resume/schema";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Label,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useImportResume } from "@/client/services/resume/import";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
enum ImportType {
|
||||
"reactive-resume-json" = "reactive-resume-json",
|
||||
"reactive-resume-v3-json" = "reactive-resume-v3-json",
|
||||
"json-resume-json" = "json-resume-json",
|
||||
"linkedin-data-export-zip" = "linkedin-data-export-zip",
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
type: z.nativeEnum(ImportType),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type ValidationResult =
|
||||
| {
|
||||
isValid: false;
|
||||
errors: string;
|
||||
}
|
||||
| {
|
||||
isValid: true;
|
||||
type: ImportType;
|
||||
result: ResumeData | ReactiveResumeV3 | LinkedIn | JsonResume;
|
||||
};
|
||||
|
||||
export const ImportDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, close } = useDialog("import");
|
||||
const { importResume, loading, error: importError } = useImportResume();
|
||||
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const filetype = form.watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) onReset();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ file: undefined, type: filetype });
|
||||
setValidationResult(null);
|
||||
}, [filetype]);
|
||||
|
||||
const accept = useMemo(() => {
|
||||
if (!filetype) return "";
|
||||
if (filetype.includes("json")) return ".json";
|
||||
if (filetype.includes("zip")) return ".zip";
|
||||
return "";
|
||||
}, [filetype]);
|
||||
|
||||
const onValidate = async () => {
|
||||
const { file, type } = formSchema.parse(form.getValues());
|
||||
|
||||
try {
|
||||
if (type === ImportType["reactive-resume-json"]) {
|
||||
const parser = new ReactiveResumeParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["reactive-resume-v3-json"]) {
|
||||
const parser = new ReactiveResumeV3Parser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["json-resume-json"]) {
|
||||
const parser = new JsonResumeParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["linkedin-data-export-zip"]) {
|
||||
const parser = new LinkedInParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = await parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
errors: error.toString(),
|
||||
});
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while validating the file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
const { type } = formSchema.parse(form.getValues());
|
||||
|
||||
if (!validationResult?.isValid || validationResult.type !== type) return;
|
||||
|
||||
try {
|
||||
if (type === ImportType["reactive-resume-json"]) {
|
||||
const parser = new ReactiveResumeParser();
|
||||
const data = parser.convert(validationResult.result as ResumeData);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["reactive-resume-v3-json"]) {
|
||||
const parser = new ReactiveResumeV3Parser();
|
||||
const data = parser.convert(validationResult.result as ReactiveResumeV3);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["json-resume-json"]) {
|
||||
const parser = new JsonResumeParser();
|
||||
const data = parser.convert(validationResult.result as JsonResume);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["linkedin-data-export-zip"]) {
|
||||
const parser = new LinkedInParser();
|
||||
const data = parser.convert(validationResult.result as LinkedIn);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while importing your resume.",
|
||||
description: importError?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
form.reset();
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<DownloadSimple />
|
||||
<h2>Import an existing resume</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a file from an external source to parse an existing resume and import it into
|
||||
Reactive Resume for easier editing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Please select a file type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reactive-resume-json">
|
||||
Reactive Resume (.json)
|
||||
</SelectItem>
|
||||
<SelectItem value="reactive-resume-v3-json">
|
||||
Reactive Resume v3 (.json)
|
||||
</SelectItem>
|
||||
<SelectItem value="json-resume-json">JSON Resume (.json)</SelectItem>
|
||||
<SelectItem value="linkedin-data-export-zip">
|
||||
LinkedIn Data Export (.zip)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="file"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
key={accept}
|
||||
accept={accept}
|
||||
onChange={(event) => {
|
||||
if (!event.target.files || !event.target.files.length) return;
|
||||
field.onChange(event.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{accept && <FormDescription>Accepts only {accept} files</FormDescription>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationResult?.isValid === false && validationResult.errors !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-error">Errors during Validation</Label>
|
||||
<ScrollArea orientation="vertical" className="h-[180px]">
|
||||
<div className="whitespace-pre-wrap rounded bg-secondary-accent p-4 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(validationResult.errors, null, 4)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{(!validationResult ?? false) && (
|
||||
<Button type="button" onClick={onValidate}>
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult !== null && !validationResult.isValid && (
|
||||
<Button type="button" variant="secondary" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult !== null && validationResult.isValid && (
|
||||
<>
|
||||
<Button type="button" onClick={onImport} disabled={loading}>
|
||||
Import
|
||||
</Button>
|
||||
|
||||
<Button disabled type="button" variant="success">
|
||||
<Check size={16} weight="bold" className="mr-2" />
|
||||
Validated
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
247
apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx
Normal file
247
apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MagicWand, Plus } from "@phosphor-icons/react";
|
||||
import { createResumeSchema, ResumeDto } from "@reactive-resume/dto";
|
||||
import { idSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Tooltip,
|
||||
} from "@reactive-resume/ui";
|
||||
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
|
||||
import { AxiosError } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume";
|
||||
import { useImportResume } from "@/client/services/resume/import";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
const formSchema = createResumeSchema.extend({ id: idSchema.optional() });
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ResumeDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, mode, payload, close } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const isUpdate = mode === "update";
|
||||
const isDelete = mode === "delete";
|
||||
const isDuplicate = mode === "duplicate";
|
||||
|
||||
const { createResume, loading: createLoading } = useCreateResume();
|
||||
const { updateResume, loading: updateLoading } = useUpdateResume();
|
||||
const { deleteResume, loading: deleteLoading } = useDeleteResume();
|
||||
const { importResume: duplicateResume, loading: duplicateLoading } = useImportResume();
|
||||
|
||||
const loading = createLoading || updateLoading || deleteLoading || duplicateLoading;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { title: "", slug: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) onReset();
|
||||
}, [isOpen, payload]);
|
||||
|
||||
useEffect(() => {
|
||||
const slug = kebabCase(form.watch("title"));
|
||||
form.setValue("slug", slug);
|
||||
}, [form.watch("title")]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createResume({ slug: values.slug, title: values.title, visibility: "private" });
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await updateResume({
|
||||
...payload.item,
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await duplicateResume({
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
data: payload.item.data,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await deleteResume({ id: payload.item?.id });
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data?.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "An error occurred while trying process your request.",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
if (isCreate) form.reset({ title: "", slug: "" });
|
||||
if (isUpdate)
|
||||
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
|
||||
if (isDuplicate)
|
||||
form.reset({ title: `${payload.item?.title} (Copy)`, slug: `${payload.item?.slug}-copy` });
|
||||
if (isDelete)
|
||||
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
|
||||
};
|
||||
|
||||
const onGenerateRandomName = () => {
|
||||
const name = generateRandomName();
|
||||
form.setValue("title", name);
|
||||
form.setValue("slug", kebabCase(name));
|
||||
};
|
||||
|
||||
if (isDelete) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete your resume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your resume and cannot
|
||||
be recovered.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<Plus />
|
||||
<h2>
|
||||
{isCreate && "Create a new resume"}
|
||||
{isUpdate && "Update an existing resume"}
|
||||
{isDuplicate && "Duplicate an existing resume"}
|
||||
</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isCreate && "Start building your resume by giving it a name."}
|
||||
{isUpdate && "Changed your mind about the name? Give it a new one."}
|
||||
{isDuplicate && "Give your old resume a new name."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<Input {...field} className="flex-1" />
|
||||
|
||||
{(isCreate || isDuplicate) && (
|
||||
<Tooltip content="Generate a random name">
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onGenerateRandomName}
|
||||
>
|
||||
<MagicWand />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Tip: You can name the resume referring to the position you are applying for.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="slug"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{isCreate && "Create"}
|
||||
{isUpdate && "Save Changes"}
|
||||
{isDuplicate && "Duplicate"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { Card } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import Tilt from "react-parallax-tilt";
|
||||
|
||||
import { defaultTiltProps } from "@/client/constants/parallax-tilt";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseCard = ({ children, className, onClick }: Props) => (
|
||||
<Tilt {...defaultTiltProps}>
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex aspect-[1/1.4142] scale-100 cursor-pointer items-center justify-center bg-secondary/50 p-0 transition-transform active:scale-95",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Tilt>
|
||||
);
|
||||
@ -0,0 +1,31 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export const CreateResumeCard = () => {
|
||||
const { open } = useDialog("resume");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<Plus size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="font-medium">
|
||||
Create a new resume
|
||||
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
|
||||
</h4>
|
||||
|
||||
<p className="text-xs opacity-75">Start from scratch</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { DownloadSimple } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export const ImportResumeCard = () => {
|
||||
const { open } = useDialog("import");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<DownloadSimple size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="line-clamp-1 font-medium">
|
||||
Import an existing resume
|
||||
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
|
||||
</h4>
|
||||
|
||||
<p className="line-clamp-1 text-xs opacity-75">LinkedIn, JSON Resume, etc.</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,121 @@
|
||||
import {
|
||||
CircleNotch,
|
||||
CopySimple,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResumePreview } from "@/client/services/resume/preview";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
type Props = {
|
||||
resume: ResumeDto;
|
||||
};
|
||||
|
||||
export const ResumeCard = ({ resume }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const { url, loading } = useResumePreview(resume.id);
|
||||
|
||||
const lastUpdated = dayjs().to(resume.updatedAt);
|
||||
|
||||
const onOpen = () => {
|
||||
navigate(`/builder/${resume.id}`);
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
open("update", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
open("duplicate", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
open("delete", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<BaseCard onClick={onOpen}>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{loading && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<CircleNotch
|
||||
size={64}
|
||||
weight="thin"
|
||||
opacity={0.5}
|
||||
className="animate-spin self-center justify-self-center"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading && url && (
|
||||
<motion.img
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="line-clamp-2 font-medium">{resume.title}</h4>
|
||||
<p className="line-clamp-1 text-xs opacity-75">{`Last updated ${lastUpdated}`}</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onOpen}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onUpdate}>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import { sortByDate } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useResumes } from "@/client/services/resume";
|
||||
|
||||
import { BaseCard } from "./_components/base-card";
|
||||
import { CreateResumeCard } from "./_components/create-card";
|
||||
import { ImportResumeCard } from "./_components/import-card";
|
||||
import { ResumeCard } from "./_components/resume-card";
|
||||
|
||||
export const GridView = () => {
|
||||
const { resumes, loading } = useResumes();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<motion.div initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: 0.1 } }}
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
|
||||
>
|
||||
<BaseCard />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resumes && (
|
||||
<AnimatePresence>
|
||||
{resumes
|
||||
.sort((a, b) => sortByDate(a, b, "updatedAt"))
|
||||
.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: (index + 2) * 0.1 } }}
|
||||
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type Props = {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
start?: React.ReactNode;
|
||||
end?: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const BaseListItem = ({ title, description, start, end, className, onClick }: Props) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center rounded p-4 transition-colors hover:bg-secondary/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex h-5 w-5 items-center justify-center">{start}</div>
|
||||
<h4 className="w-[220px] truncate font-medium lg:w-[320px]">{title}</h4>
|
||||
<p className="hidden text-xs opacity-75 sm:block">{description}</p>
|
||||
</div>
|
||||
|
||||
{end && <div className="flex h-5 w-5 items-center justify-center">{end}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,25 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
export const CreateResumeListItem = () => {
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<Plus size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>Create a new resume</span>
|
||||
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
|
||||
</>
|
||||
}
|
||||
description="Start building from scratch"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { DownloadSimple } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
export const ImportResumeListItem = () => {
|
||||
const { open } = useDialog("import");
|
||||
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<DownloadSimple size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>Import an existing resume</span>
|
||||
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
|
||||
</>
|
||||
}
|
||||
description="LinkedIn, JSON Resume, etc."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,163 @@
|
||||
import {
|
||||
CopySimple,
|
||||
DotsThreeVertical,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import {
|
||||
Button,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResumePreview } from "@/client/services/resume/preview";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
type Props = {
|
||||
resume: ResumeDto;
|
||||
};
|
||||
|
||||
export const ResumeListItem = ({ resume }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const { url } = useResumePreview(resume.id);
|
||||
|
||||
const lastUpdated = dayjs().to(resume.updatedAt);
|
||||
|
||||
const onOpen = () => {
|
||||
navigate(`/builder/${resume.id}`);
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
open("update", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
open("duplicate", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
open("delete", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const dropdownMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="aspect-square">
|
||||
<Button size="icon" variant="ghost">
|
||||
<DotsThreeVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onUpdate();
|
||||
}}
|
||||
>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-error"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="even:bg-secondary/20">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<BaseListItem
|
||||
onClick={onOpen}
|
||||
className="group"
|
||||
title={resume.title}
|
||||
description={`Last updated ${lastUpdated}`}
|
||||
end={dropdownMenu}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="p-0" sideOffset={-100} alignOffset={100}>
|
||||
<AnimatePresence>
|
||||
{url && (
|
||||
<motion.img
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="aspect-[1/1.4142] w-60 rounded-sm object-cover"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onOpen}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onUpdate}>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,56 @@
|
||||
import { sortByDate } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useResumes } from "@/client/services/resume";
|
||||
|
||||
import { BaseListItem } from "./_components/base-item";
|
||||
import { CreateResumeListItem } from "./_components/create-item";
|
||||
import { ImportResumeListItem } from "./_components/import-item";
|
||||
import { ResumeListItem } from "./_components/resume-item";
|
||||
|
||||
export const ListView = () => {
|
||||
const { resumes, loading } = useResumes();
|
||||
|
||||
return (
|
||||
<div className="grid gap-y-2">
|
||||
<motion.div initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<CreateResumeListItem />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }}
|
||||
>
|
||||
<ImportResumeListItem />
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
|
||||
>
|
||||
<BaseListItem className="bg-secondary/40" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resumes && (
|
||||
<AnimatePresence>
|
||||
{resumes
|
||||
.sort((a, b) => sortByDate(a, b, "updatedAt"))
|
||||
.map((resume, index) => (
|
||||
<motion.div
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: (index + 2) * 0.1 } }}
|
||||
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
apps/client/src/pages/dashboard/resumes/page.tsx
Normal file
54
apps/client/src/pages/dashboard/resumes/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { List, SquaresFour } from "@phosphor-icons/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { GridView } from "./_layouts/grid";
|
||||
import { ListView } from "./_layouts/list";
|
||||
|
||||
type Layout = "grid" | "list";
|
||||
|
||||
export const ResumesPage = () => {
|
||||
const [layout, setLayout] = useState<Layout>("grid");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Resumes - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<Tabs value={layout} onValueChange={(value) => setLayout(value as Layout)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-4xl font-bold tracking-tight"
|
||||
>
|
||||
Resumes
|
||||
</motion.h1>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
|
||||
<SquaresFour />
|
||||
<span className="ml-2 hidden sm:block">Grid</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
|
||||
<List />
|
||||
<span className="ml-2 hidden sm:block">List</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 md:mt-8">
|
||||
<TabsContent value="grid">
|
||||
<GridView />
|
||||
</TabsContent>
|
||||
<TabsContent value="list">
|
||||
<ListView />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
264
apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx
Normal file
264
apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { QrCode } from "@phosphor-icons/react";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { useDisable2FA, useEnable2FA, useSetup2FA } from "@/client/services/auth";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
// We're using the pre-existing "mode" state to determine the stage of 2FA setup the user is in.
|
||||
// - "create" mode is used to enable 2FA.
|
||||
// - "update" mode is used to verify 2FA, displaying a QR Code, once enabled.
|
||||
// - "duplicate" mode is used to display the backup codes after initial verification.
|
||||
// - "delete" mode is used to disable 2FA.
|
||||
|
||||
const formSchema = z.object({
|
||||
uri: z.literal("").or(z.string().optional()),
|
||||
code: z.literal("").or(z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits long.")),
|
||||
backupCodes: z.array(z.string()),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const TwoFactorDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, mode, open, close } = useDialog("two-factor");
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const isUpdate = mode === "update";
|
||||
const isDelete = mode === "delete";
|
||||
const isDuplicate = mode === "duplicate";
|
||||
|
||||
const { setup2FA, loading: setupLoading } = useSetup2FA();
|
||||
const { enable2FA, loading: enableLoading } = useEnable2FA();
|
||||
const { disable2FA, loading: disableLoading } = useDisable2FA();
|
||||
|
||||
const loading = setupLoading || enableLoading || disableLoading;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { uri: "", code: "", backupCodes: [] },
|
||||
});
|
||||
|
||||
// If the user is enabling 2FA, we need to get the QR code URI from the server.
|
||||
// And display the QR code to the user.
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const data = await setup2FA();
|
||||
form.setValue("uri", data.message);
|
||||
};
|
||||
|
||||
if (isCreate) initialize();
|
||||
}, [isCreate]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (isCreate) {
|
||||
open("update");
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
if (!values.code) return;
|
||||
|
||||
try {
|
||||
const data = await enable2FA({ code: values.code });
|
||||
form.setValue("backupCodes", data.backupCodes);
|
||||
await queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
|
||||
open("duplicate");
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data?.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "An error occurred while trying to enable two-factor authentication.",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
close();
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
const data = await disable2FA();
|
||||
toast({ variant: "success", title: data.message });
|
||||
await queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (isDelete) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to disable two-factor authentication?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
If you disable two-factor authentication, you will no longer be required to enter
|
||||
a verification code when logging in.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Alert variant="info">
|
||||
<AlertDescription>Note: This will make your account less secure.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||
Disable
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent className="!max-w-md">
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<QrCode />
|
||||
<h2>
|
||||
{mode === "create" && "Setup two-factor authentication on your account"}
|
||||
{mode === "update" &&
|
||||
"Verify that two-factor authentication has been setup correctly"}
|
||||
{mode === "duplicate" && "Store your backup codes securely"}
|
||||
</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isCreate &&
|
||||
"Scan the QR code below with your authenticator app to setup 2FA on your account."}
|
||||
{isUpdate &&
|
||||
"Enter the 6-digit code from your authenticator app to verify that 2FA has been setup correctly."}
|
||||
{isDuplicate && "You have enabled two-factor authentication successfully."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCreate && (
|
||||
<FormField
|
||||
name="uri"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
<QRCodeSVG value={field.value!} size={256} className="mx-auto" />
|
||||
<Input readOnly {...field} className="opacity-75" />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
In case you don't have access to your camera, you can also copy-paste this URI
|
||||
to your authenticator app.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUpdate && (
|
||||
<FormField
|
||||
name="code"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDuplicate && (
|
||||
<>
|
||||
<FormField
|
||||
name="backupCodes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mx-auto grid max-w-xs grid-cols-2 rounded-sm bg-secondary/50 p-4 text-center font-mono leading-loose">
|
||||
{field.value.map((code) => (
|
||||
<p key={code}>{code}</p>
|
||||
))}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-xs leading-relaxed">
|
||||
Please store your backup codes in a secure location. You can use one of these
|
||||
one-time use codes to login in case you lose access to your authenticator app.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isCreate && <Button disabled={loading}>Continue</Button>}
|
||||
{isUpdate && (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => open("create")}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button disabled={loading}>Continue</Button>
|
||||
</>
|
||||
)}
|
||||
{isDuplicate && <Button disabled={loading}>Close</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
231
apps/client/src/pages/dashboard/settings/_sections/account.tsx
Normal file
231
apps/client/src/pages/dashboard/settings/_sections/account.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, UploadSimple, Warning } from "@phosphor-icons/react";
|
||||
import { UpdateUserDto, updateUserSchema } from "@reactive-resume/dto";
|
||||
import {
|
||||
Button,
|
||||
buttonVariants,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { UserAvatar } from "@/client/components/user-avatar";
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useResendVerificationEmail } from "@/client/services/auth";
|
||||
import { useUploadImage } from "@/client/services/storage";
|
||||
import { useUpdateUser, useUser } from "@/client/services/user";
|
||||
|
||||
export const AccountSettings = () => {
|
||||
const { user } = useUser();
|
||||
const { toast } = useToast();
|
||||
const { updateUser, loading } = useUpdateUser();
|
||||
const { uploadImage, loading: isUploading } = useUploadImage();
|
||||
const { resendVerificationEmail } = useResendVerificationEmail();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<UpdateUserDto>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
picture: "",
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
user && onReset();
|
||||
}, [user]);
|
||||
|
||||
const onReset = () => {
|
||||
if (!user) return;
|
||||
|
||||
form.reset({
|
||||
picture: user.picture ?? "",
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: UpdateUserDto) => {
|
||||
if (!user) return;
|
||||
|
||||
// Check if email has changed and display a toast message to confirm the email change
|
||||
if (user.email !== data.email) {
|
||||
toast({
|
||||
variant: "info",
|
||||
title: "Check your email for the confirmation link to update your email address.",
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
picture: data.picture,
|
||||
username: data.username,
|
||||
});
|
||||
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
const file = event.target.files[0];
|
||||
const response = await uploadImage(file);
|
||||
const url = response.data;
|
||||
|
||||
await updateUser({ picture: url });
|
||||
}
|
||||
};
|
||||
|
||||
const onResendVerificationEmail = async () => {
|
||||
const data = await resendVerificationEmail();
|
||||
|
||||
toast({ variant: "success", title: data.message });
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Account</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
Here, you can update your account information such as your profile picture, name and
|
||||
username.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="picture"
|
||||
control={form.control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<div className={cn("flex items-end gap-x-4 sm:col-span-2", error && "items-center")}>
|
||||
<UserAvatar />
|
||||
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Picture</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
{!user.picture && (
|
||||
<>
|
||||
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
|
||||
|
||||
<motion.button
|
||||
disabled={isUploading}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||
>
|
||||
<UploadSimple />
|
||||
</motion.button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{fieldState.error && (
|
||||
<FormDescription className="text-error">
|
||||
{fieldState.error.message}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5 font-medium opacity-100",
|
||||
user.emailVerified ? "text-success-accent" : "text-warning-accent",
|
||||
)}
|
||||
>
|
||||
{user.emailVerified ? <Check size={12} /> : <Warning size={12} />}
|
||||
{user.emailVerified ? "Verified" : "Unverified"}
|
||||
{!user.emailVerified && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto text-xs"
|
||||
onClick={onResendVerificationEmail}
|
||||
>
|
||||
Resend confirmation link
|
||||
</Button>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="flex items-center space-x-2 self-center sm:col-start-2"
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCounter } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useLogout } from "@/client/services/auth";
|
||||
import { useDeleteUser } from "@/client/services/user";
|
||||
|
||||
const formSchema = z.object({
|
||||
deleteConfirm: z.literal("delete"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const DangerZoneSettings = () => {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useLogout();
|
||||
const { count, increment } = useCounter(0);
|
||||
const { deleteUser, loading } = useDeleteUser();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
deleteConfirm: "" as FormValues["deleteConfirm"],
|
||||
},
|
||||
});
|
||||
|
||||
const onDelete = async () => {
|
||||
// On the first click, increment the counter
|
||||
increment();
|
||||
|
||||
// On the second click, delete the account
|
||||
if (count === 1) {
|
||||
await Promise.all([deleteUser, logout]);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Your account has been deleted successfully.",
|
||||
});
|
||||
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Danger Zone</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
In this section, you can delete your account and all the data associated to your user, but
|
||||
please keep in mind that{" "}
|
||||
<span className="font-semibold">this action is irreversible</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onDelete)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="deleteConfirm"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delete Account</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="delete" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Type <code className="font-bold">delete</code> to confirm deleting your account.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
<Button type="submit" variant="error" disabled={!form.formState.isValid || loading}>
|
||||
{count === 1 ? "Are you sure?" : "Delete Account"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
apps/client/src/pages/dashboard/settings/_sections/openai.tsx
Normal file
147
apps/client/src/pages/dashboard/settings/_sections/openai.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { LockSimple, LockSimpleOpen, TrashSimple } from "@phosphor-icons/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOpenAiStore } from "@/client/stores/openai";
|
||||
|
||||
const formSchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
.regex(/^sk-[a-zA-Z0-9]+$/, "That doesn't look like a valid OpenAI API key.")
|
||||
.default(""),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const OpenAISettings = () => {
|
||||
const { apiKey, setApiKey } = useOpenAiStore();
|
||||
const isEnabled = !!apiKey;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { apiKey: apiKey ?? "" },
|
||||
});
|
||||
|
||||
const onSubmit = async ({ apiKey }: FormValues) => {
|
||||
setApiKey(apiKey);
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
setApiKey(null);
|
||||
form.reset({ apiKey: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">OpenAI Integration</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
You can make use of the OpenAI API to help you generate content, or improve your writing
|
||||
while composing your resume.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
|
||||
<p>
|
||||
You have the option to{" "}
|
||||
<a
|
||||
href="https://www.howtogeek.com/885918/how-to-get-an-openai-api-key/"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
obtain your own OpenAI API key
|
||||
</a>
|
||||
. This key empowers you to leverage the API as you see fit. Alternatively, if you wish to
|
||||
disable the AI features in Reactive Resume altogether, you can simply remove the key from
|
||||
your settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="apiKey"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="sk-..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2 self-end sm:col-start-2",
|
||||
!!form.formState.errors.apiKey && "self-center",
|
||||
)}
|
||||
>
|
||||
<Button type="submit" disabled={isEnabled || !form.formState.isDirty}>
|
||||
{!isEnabled && <LockSimpleOpen className="mr-2" />}
|
||||
{isEnabled && <LockSimple className="mr-2" />}
|
||||
{isEnabled ? "Saved" : "Save Locally"}
|
||||
</Button>
|
||||
|
||||
{isEnabled && (
|
||||
<Button type="reset" variant="ghost" onClick={onRemove}>
|
||||
<TrashSimple className="mr-2" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
|
||||
<p>
|
||||
Your API key is securely stored in the browser's local storage and is only utilized when
|
||||
making requests to OpenAI via their official SDK. Rest assured that your key is not
|
||||
transmitted to any external server except when interacting with OpenAI's services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<div className="prose prose-neutral max-w-full text-xs leading-relaxed text-primary dark:prose-invert">
|
||||
<span className="font-medium">Note: </span>
|
||||
<span>
|
||||
By utilizing the OpenAI API, you acknowledge and accept the{" "}
|
||||
<a
|
||||
href="https://openai.com/policies/terms-of-use"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
terms of use
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://openai.com/policies/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
privacy policy
|
||||
</a>{" "}
|
||||
outlined by OpenAI. Please note that Reactive Resume bears no responsibility for any
|
||||
improper or unauthorized utilization of the service, and any resulting repercussions or
|
||||
liabilities solely rest on the user.
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
apps/client/src/pages/dashboard/settings/_sections/profile.tsx
Normal file
138
apps/client/src/pages/dashboard/settings/_sections/profile.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { Combobox } from "@reactive-resume/ui";
|
||||
import { Form, FormDescription, FormField, FormItem, FormLabel } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useUpdateUser, useUser } from "@/client/services/user";
|
||||
|
||||
const formSchema = z.object({
|
||||
theme: z.enum(["system", "light", "dark"]).default("system"),
|
||||
language: z.string().default("en"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProfileSettings = () => {
|
||||
const { user } = useUser();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { updateUser, loading } = useUpdateUser();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { theme, language: "en" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
user && onReset();
|
||||
}, [user]);
|
||||
|
||||
const onReset = () => {
|
||||
if (!user) return;
|
||||
|
||||
form.reset({ theme, language: user.language ?? "en" });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
if (!user) return;
|
||||
|
||||
setTheme(data.theme);
|
||||
|
||||
if (user.language !== data.language) {
|
||||
await updateUser({ language: data.language });
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Profile</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
Here, you can update your profile to customize and personalize your experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="theme"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<div className="w-full">
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="language"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<div className="w-full">
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{
|
||||
value: "en",
|
||||
label: <p>English</p>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
<span>
|
||||
Don't see your language?{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
href="https://translate.rxresu.me/"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Help translate the app.
|
||||
</a>
|
||||
</span>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"hidden items-center space-x-2 self-center sm:col-start-2",
|
||||
form.formState.isDirty && "flex animate-in fade-in",
|
||||
)}
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
162
apps/client/src/pages/dashboard/settings/_sections/security.tsx
Normal file
162
apps/client/src/pages/dashboard/settings/_sections/security.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useUpdatePassword } from "@/client/services/auth";
|
||||
import { useUser } from "@/client/services/user";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
password: z.string().min(6),
|
||||
confirmPassword: z.string().min(6),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "The passwords you entered do not match.",
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const SecuritySettings = () => {
|
||||
const { user } = useUser();
|
||||
const { toast } = useToast();
|
||||
const { open } = useDialog("two-factor");
|
||||
const { updatePassword, loading } = useUpdatePassword();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
form.reset({ password: "", confirmPassword: "" });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updatePassword({ password: data.password });
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Your password has been updated successfully.",
|
||||
});
|
||||
|
||||
onReset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Security</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
In this section, you can change your password and enable/disable two-factor
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" defaultValue={["password", "two-factor"]}>
|
||||
<AccordionItem value="password">
|
||||
<AccordionTrigger>Password</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="confirmPassword"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
{fieldState.error && (
|
||||
<FormDescription className="text-error-foreground">
|
||||
{fieldState.error?.message}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="flex items-center space-x-2 self-center sm:col-start-2"
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Change Password
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="two-factor">
|
||||
<AccordionTrigger>Two-Factor Authentication</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{user?.twoFactorEnabled ? (
|
||||
<p className="mb-4 leading-relaxed opacity-75">
|
||||
<strong>Two-factor authentication is enabled.</strong> You will be asked to enter a
|
||||
code every time you sign in.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 leading-relaxed opacity-75">
|
||||
<strong>Two-factor authentication is currently disabled.</strong> You can enable it
|
||||
by adding an authenticator app to your account.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user?.twoFactorEnabled ? (
|
||||
<Button variant="outline" onClick={() => open("delete")}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => open("create")}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
apps/client/src/pages/dashboard/settings/page.tsx
Normal file
37
apps/client/src/pages/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Separator } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { AccountSettings } from "./_sections/account";
|
||||
import { DangerZoneSettings } from "./_sections/danger";
|
||||
import { OpenAISettings } from "./_sections/openai";
|
||||
import { ProfileSettings } from "./_sections/profile";
|
||||
import { SecuritySettings } from "./_sections/security";
|
||||
|
||||
export const SettingsPage = () => (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Settings - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-2xl space-y-8 pb-12">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-4xl font-bold tracking-tight"
|
||||
>
|
||||
Settings
|
||||
</motion.h1>
|
||||
|
||||
<AccountSettings />
|
||||
<Separator />
|
||||
<SecuritySettings />
|
||||
<Separator />
|
||||
<ProfileSettings />
|
||||
<Separator />
|
||||
<OpenAISettings />
|
||||
<Separator />
|
||||
<DangerZoneSettings />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
32
apps/client/src/pages/home/components/footer.tsx
Normal file
32
apps/client/src/pages/home/components/footer.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Separator } from "@reactive-resume/ui";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { Logo } from "@/client/components/logo";
|
||||
import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className="fixed inset-x-0 bottom-0 -z-50 h-[400px] bg-background">
|
||||
<Separator />
|
||||
|
||||
<div className="container grid py-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Logo size={96} className="-ml-2" />
|
||||
|
||||
<h2 className="text-xl font-medium">Reactive Resume</h2>
|
||||
|
||||
<p className="prose prose-sm prose-zinc leading-relaxed opacity-60 dark:prose-invert">
|
||||
A free and open-source resume builder that simplifies the tasks of creating, updating, and
|
||||
sharing your resume.
|
||||
</p>
|
||||
|
||||
<Copyright className="mt-6" />
|
||||
</div>
|
||||
|
||||
<div className="relative col-start-4">
|
||||
<div className="absolute bottom-0 right-0">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
23
apps/client/src/pages/home/components/header.tsx
Normal file
23
apps/client/src/pages/home/components/header.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/client/components/logo";
|
||||
|
||||
export const Header = () => (
|
||||
<motion.header
|
||||
className="fixed inset-x-0 top-0 z-20"
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-background to-transparent py-3">
|
||||
<div className="container flex items-center justify-between">
|
||||
<Link to="/">
|
||||
<Logo size={48} />
|
||||
</Link>
|
||||
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
12
apps/client/src/pages/home/layout.tsx
Normal file
12
apps/client/src/pages/home/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { Footer } from "./components/footer";
|
||||
import { Header } from "./components/header";
|
||||
|
||||
export const HomeLayout = () => (
|
||||
<>
|
||||
<Header />
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
17
apps/client/src/pages/home/page.tsx
Normal file
17
apps/client/src/pages/home/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { HeroSection } from "./sections/hero";
|
||||
import { LogoCloudSection } from "./sections/logo-cloud";
|
||||
import { StatisticsSection } from "./sections/statistics";
|
||||
|
||||
export const HomePage = () => (
|
||||
<main className="relative isolate mb-[400px] overflow-hidden bg-background">
|
||||
<Helmet>
|
||||
<title>Reactive Resume - A free and open-source resume builder</title>
|
||||
</Helmet>
|
||||
|
||||
<HeroSection />
|
||||
<LogoCloudSection />
|
||||
<StatisticsSection />
|
||||
</main>
|
||||
);
|
||||
46
apps/client/src/pages/home/sections/hero/call-to-action.tsx
Normal file
46
apps/client/src/pages/home/sections/hero/call-to-action.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Book, SignOut } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useLogout } from "@/client/services/auth";
|
||||
import { useAuthStore } from "@/client/stores/auth";
|
||||
|
||||
export const HeroCTA = () => {
|
||||
const { logout } = useLogout();
|
||||
|
||||
const isLoggedIn = useAuthStore((state) => !!state.user);
|
||||
|
||||
if (isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
|
||||
<Button size="lg" variant="link" onClick={() => logout()}>
|
||||
<SignOut className="mr-3" />
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/auth/login">Get started</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" variant="link">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer nofollow">
|
||||
<Book className="mr-3" />
|
||||
Learn more
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
47
apps/client/src/pages/home/sections/hero/decoration.tsx
Normal file
47
apps/client/src/pages/home/sections/hero/decoration.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
export const Decoration = {
|
||||
Grid: () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-foreground/10 opacity-60 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)] dark:opacity-40"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc"
|
||||
width={200}
|
||||
height={200}
|
||||
x="50%"
|
||||
y={-1}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<svg x="50%" y={-1} className="overflow-visible fill-border/20">
|
||||
<path
|
||||
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
Gradient: () => (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute left-[calc(50%-4rem)] top-10 -z-10 transform-gpu blur-3xl sm:left-[calc(50%-18rem)] lg:left-48 lg:top-[calc(50%-30rem)] xl:left-[calc(50%-24rem)]"
|
||||
>
|
||||
<div
|
||||
className="aspect-[1108/632] h-96 w-[69.25rem] bg-gradient-to-r from-[#6f8cbb] to-[#c93b37] opacity-40 dark:opacity-20"
|
||||
style={{
|
||||
clipPath:
|
||||
"polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
65
apps/client/src/pages/home/sections/hero/index.tsx
Normal file
65
apps/client/src/pages/home/sections/hero/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { ArrowRight } from "@phosphor-icons/react";
|
||||
import { Badge, Button } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import Tilt from "react-parallax-tilt";
|
||||
|
||||
import { defaultTiltProps } from "@/client/constants/parallax-tilt";
|
||||
|
||||
import { HeroCTA } from "./call-to-action";
|
||||
import { Decoration } from "./decoration";
|
||||
|
||||
export const HeroSection = () => (
|
||||
<section className="relative">
|
||||
<Decoration.Grid />
|
||||
<Decoration.Gradient />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:flex lg:h-screen lg:items-center lg:px-12">
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl shrink-0 lg:mx-0 lg:max-w-xl lg:pt-8"
|
||||
initial={{ opacity: 0, x: -100 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
<div className="mt-24 flex items-center gap-x-4 sm:mt-32 lg:mt-0">
|
||||
<Badge>Version 4</Badge>
|
||||
|
||||
<Button variant="link" className="space-x-2 text-left">
|
||||
<p>What's new in the latest version</p>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-2">
|
||||
<h6 className="text-base font-bold tracking-wide">Finally,</h6>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
A free and open-source resume builder.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="prose prose-base prose-zinc mt-6 text-lg leading-8 dark:prose-invert">
|
||||
A free and open-source resume builder that simplifies the process of creating, updating,
|
||||
and sharing your resume.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex items-center gap-x-8">
|
||||
<HeroCTA />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mx-auto mt-16 flex max-w-2xl sm:mt-24 lg:ml-10 lg:mr-0 lg:mt-0 lg:max-w-none lg:flex-none xl:ml-20">
|
||||
<div className="max-w-3xl flex-none sm:max-w-5xl lg:max-w-none">
|
||||
<motion.div initial={{ opacity: 0, x: 100 }} whileInView={{ opacity: 1, x: 0 }}>
|
||||
<Tilt {...defaultTiltProps}>
|
||||
<img
|
||||
width={3600}
|
||||
height={2078}
|
||||
src="/screenshots/builder.png"
|
||||
alt="Reactive Resume - Screenshot - Builder Screen"
|
||||
className="w-[76rem] rounded-lg bg-background/5 shadow-2xl ring-1 ring-foreground/10"
|
||||
/>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
60
apps/client/src/pages/home/sections/logo-cloud/index.tsx
Normal file
60
apps/client/src/pages/home/sections/logo-cloud/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type LogoProps = { company: string };
|
||||
|
||||
const Logo = ({ company }: LogoProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-2 col-start-2 sm:col-start-auto lg:col-span-1",
|
||||
company === "twilio" && "sm:col-start-2",
|
||||
)}
|
||||
>
|
||||
{/* Show on Light Theme */}
|
||||
<img
|
||||
className="block max-h-12 object-contain dark:hidden"
|
||||
src={`/brand-logos/dark/${company}.svg`}
|
||||
alt={company}
|
||||
width={212}
|
||||
height={48}
|
||||
/>
|
||||
{/* Show on Dark Theme */}
|
||||
<img
|
||||
className="hidden max-h-12 object-contain dark:block"
|
||||
src={`/brand-logos/light/${company}.svg`}
|
||||
alt={company}
|
||||
width={212}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const logoList: string[] = ["amazon", "google", "postman", "twilio", "zalando"];
|
||||
|
||||
export const LogoCloudSection = () => (
|
||||
<section className="relative py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<p className="text-center text-lg leading-relaxed">
|
||||
Reactive Resume has helped people land jobs at these great companies:
|
||||
</p>
|
||||
<div className="mx-auto mt-10 grid max-w-lg grid-cols-4 items-center gap-x-8 gap-y-10 sm:max-w-xl sm:grid-cols-6 sm:gap-x-10 lg:mx-0 lg:max-w-none lg:grid-cols-5">
|
||||
{logoList.map((company) => (
|
||||
<Logo key={company} company={company} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mx-auto mt-8 max-w-sm text-center leading-relaxed">
|
||||
If this app has helped you with your job hunt, let me know by reaching out through{" "}
|
||||
<Button asChild variant="link" className="p-0">
|
||||
<a
|
||||
href="https://www.amruthpillai.com/#contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
this contact form
|
||||
</a>
|
||||
</Button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
57
apps/client/src/pages/home/sections/statistics/counter.tsx
Normal file
57
apps/client/src/pages/home/sections/statistics/counter.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { animate, motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type CounterProps = { from: number; to: number };
|
||||
|
||||
export const Counter = ({ from, to }: CounterProps) => {
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const nodeRef = useRef<HTMLParagraphElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(node);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const node = nodeRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const controls = animate(from, to, {
|
||||
duration: 1,
|
||||
onUpdate(value) {
|
||||
node.textContent = Math.round(value).toLocaleString();
|
||||
},
|
||||
});
|
||||
|
||||
return () => controls.stop();
|
||||
}, [from, to, isInView]);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={nodeRef}
|
||||
transition={{ duration: 0.5 }}
|
||||
initial={{ opacity: 0, scale: 0.1 }}
|
||||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
apps/client/src/pages/home/sections/statistics/index.tsx
Normal file
29
apps/client/src/pages/home/sections/statistics/index.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Counter } from "./counter";
|
||||
|
||||
type Statistic = {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const stats: Statistic[] = [
|
||||
{ name: "GitHub Stars", value: 11800 },
|
||||
{ name: "Users Signed Up", value: 300000 },
|
||||
{ name: "Resumes Generated", value: 400000 },
|
||||
];
|
||||
|
||||
export const StatisticsSection = () => (
|
||||
<section className="relative py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-16 text-center lg:grid-cols-3">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="mx-auto flex max-w-xs flex-col gap-y-4">
|
||||
<dt className="text-base leading-7 opacity-60">{stat.name}</dt>
|
||||
<dd className="order-first text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||
<Counter from={0} to={stat.value} />+
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
20
apps/client/src/pages/printer/page.tsx
Normal file
20
apps/client/src/pages/printer/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { ResumeData, SectionKey } from "@reactive-resume/schema";
|
||||
import { Artboard, PageWrapper, Rhyhorn } from "@reactive-resume/templates";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useSessionStorage } from "usehooks-ts";
|
||||
|
||||
export const PrinterPage = () => {
|
||||
const [resume] = useSessionStorage<ResumeData | null>("resume", null);
|
||||
|
||||
if (!resume) return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
|
||||
{resume.metadata.layout.map((columns, pageIndex) => (
|
||||
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
|
||||
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||
</PageWrapper>
|
||||
))}
|
||||
</Artboard>
|
||||
);
|
||||
};
|
||||
74
apps/client/src/pages/public/page.tsx
Normal file
74
apps/client/src/pages/public/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
import { Artboard, PageWrapper, Rhyhorn } from "@reactive-resume/templates";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { pageSizeMap } from "@reactive-resume/utils";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Link, LoaderFunction, redirect, useLoaderData } from "react-router-dom";
|
||||
|
||||
import { Icon } from "@/client/components/icon";
|
||||
import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { findResumeByUsernameSlug } from "@/client/services/resume";
|
||||
|
||||
export const PublicResumePage = () => {
|
||||
const { title, data: resume } = useLoaderData() as ResumeDto;
|
||||
const format = resume.metadata.page.format;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>{title} - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
style={{ width: `${pageSizeMap[format].width}mm` }}
|
||||
className="mx-auto mb-6 mt-16 flex shadow-xl print:m-0 print:shadow-none"
|
||||
>
|
||||
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
|
||||
{resume.metadata.layout.map((columns, pageIndex) => (
|
||||
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
|
||||
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||
</PageWrapper>
|
||||
))}
|
||||
</Artboard>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-10 opacity-50 print:hidden">
|
||||
<Link to="/">
|
||||
<Button size="sm" variant="ghost" className="space-x-1.5 text-xs font-normal">
|
||||
<span>Built with</span>
|
||||
<Icon size={12} />
|
||||
<span>Reactive Resume</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-5 right-5 print:hidden">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const publicLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
|
||||
try {
|
||||
const username = params.username as string;
|
||||
const slug = params.slug as string;
|
||||
|
||||
const resume = await queryClient.fetchQuery({
|
||||
queryKey: ["resume", { username, slug }],
|
||||
queryFn: () => findResumeByUsernameSlug({ username, slug }),
|
||||
});
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "The resume you were looking for was nowhere to be found... or maybe never existed?",
|
||||
});
|
||||
|
||||
return redirect("/");
|
||||
}
|
||||
};
|
||||
55
apps/client/src/providers/dialog.tsx
Normal file
55
apps/client/src/providers/dialog.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { AwardsDialog } from "../pages/builder/sidebars/left/dialogs/awards";
|
||||
import { CertificationsDialog } from "../pages/builder/sidebars/left/dialogs/certifications";
|
||||
import { CustomSectionDialog } from "../pages/builder/sidebars/left/dialogs/custom-section";
|
||||
import { EducationDialog } from "../pages/builder/sidebars/left/dialogs/education";
|
||||
import { ExperienceDialog } from "../pages/builder/sidebars/left/dialogs/experience";
|
||||
import { InterestsDialog } from "../pages/builder/sidebars/left/dialogs/interests";
|
||||
import { LanguagesDialog } from "../pages/builder/sidebars/left/dialogs/languages";
|
||||
import { ProfilesDialog } from "../pages/builder/sidebars/left/dialogs/profiles";
|
||||
import { ProjectsDialog } from "../pages/builder/sidebars/left/dialogs/projects";
|
||||
import { PublicationsDialog } from "../pages/builder/sidebars/left/dialogs/publications";
|
||||
import { ReferencesDialog } from "../pages/builder/sidebars/left/dialogs/references";
|
||||
import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills";
|
||||
import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer";
|
||||
import { ImportDialog } from "../pages/dashboard/resumes/_dialogs/import";
|
||||
import { ResumeDialog } from "../pages/dashboard/resumes/_dialogs/resume";
|
||||
import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor";
|
||||
import { useResumeStore } from "../stores/resume";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DialogProvider = ({ children }: Props) => {
|
||||
const isResumeLoaded = useResumeStore((state) => Object.keys(state.resume).length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
<div id="dialog-root">
|
||||
<ResumeDialog />
|
||||
<ImportDialog />
|
||||
<TwoFactorDialog />
|
||||
|
||||
{isResumeLoaded && (
|
||||
<>
|
||||
<ProfilesDialog />
|
||||
<ExperienceDialog />
|
||||
<EducationDialog />
|
||||
<AwardsDialog />
|
||||
<CertificationsDialog />
|
||||
<InterestsDialog />
|
||||
<LanguagesDialog />
|
||||
<ProjectsDialog />
|
||||
<PublicationsDialog />
|
||||
<VolunteerDialog />
|
||||
<SkillsDialog />
|
||||
<ReferencesDialog />
|
||||
<CustomSectionDialog />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user