mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
refactor(ui): improve error handling and input safety in app flows
Normalize frontend error rendering and tighten input/path handling across auth, builder, dashboard, and shared components for more resilient UX behavior. Made-with: Cursor
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useRef } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
@@ -81,33 +82,65 @@ export function CommandPalette() {
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only print:hidden">
|
||||
<DialogTitle>
|
||||
<Trans>Builder Command Palette</Trans>
|
||||
<Trans comment="Screen-reader dialog title for the command palette in the resume builder">
|
||||
Builder Command Palette
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Type a command or search...</Trans>
|
||||
<Trans comment="Screen-reader dialog description instructing users how to use the command palette">
|
||||
Type a command or search...
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
|
||||
aria-label={
|
||||
isFirstPage
|
||||
? t({
|
||||
comment: "Accessible label for the command palette dialog",
|
||||
message: "Command Palette",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for command palette dialog when browsing a nested command page",
|
||||
message: `Command Palette - ${currentPage}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
aria-label="Command Palette"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for command list region inside command palette",
|
||||
message: "Command Palette",
|
||||
})}
|
||||
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
|
||||
aria-label="Search commands"
|
||||
placeholder={
|
||||
isFirstPage
|
||||
? t({
|
||||
comment: "Placeholder in command palette input on root page",
|
||||
message: "Type a command or search...",
|
||||
})
|
||||
: t({
|
||||
comment: "Placeholder in command palette input on nested pages",
|
||||
message: "Search...",
|
||||
})
|
||||
}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for command palette search input",
|
||||
message: "Search commands",
|
||||
})}
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>The command you're looking for doesn't exist.</Trans>
|
||||
<Trans comment="Empty-state message when no command palette results match the search query">
|
||||
The command you're looking for doesn't exist.
|
||||
</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<ResumesCommandGroup />
|
||||
|
||||
@@ -73,7 +73,9 @@ export function ResumesCommandGroup() {
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
@@ -78,7 +79,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Edit ${chip}`}
|
||||
aria-label={t({
|
||||
comment:
|
||||
"Screen reader label for button that edits a keyword chip. Variable is the current keyword text.",
|
||||
message: `Edit ${chip}`,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
@@ -90,7 +95,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
aria-label={t({
|
||||
comment:
|
||||
"Screen reader label for button that removes a keyword chip. Variable is the current keyword text.",
|
||||
message: `Remove ${chip}`,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
@@ -122,6 +131,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
const [input, setInput] = React.useState("");
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isEditingKeyword = editingIndex !== null;
|
||||
|
||||
const addChip = React.useCallback(
|
||||
(chip: string) => {
|
||||
@@ -289,8 +299,8 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
type="text"
|
||||
value={input}
|
||||
autoComplete="off"
|
||||
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
|
||||
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
|
||||
aria-label={isEditingKeyword ? t`Edit keyword` : t`Add keyword`}
|
||||
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
@@ -24,12 +24,17 @@ type IconSearchInputProps = {
|
||||
function _IconSearchInput(props: IconSearchInputProps) {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
value={props.value}
|
||||
aria-label={t`Search for an icon`}
|
||||
placeholder={t`Search for an icon`}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for icon picker search input",
|
||||
message: "Search for an icon",
|
||||
})}
|
||||
placeholder={t({
|
||||
comment: "Placeholder text in icon picker search input",
|
||||
message: "Search for an icon",
|
||||
})}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
|
||||
/>
|
||||
|
||||
@@ -172,8 +172,16 @@ export function RichInput({ value, onChange, style, className, editorClassName,
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent className="flex h-[95svh] max-h-none! w-[95svw] max-w-none! flex-col p-4 sm:max-w-none! 2xl:max-w-none!">
|
||||
<div className="sr-only">
|
||||
<DialogTitle>Fullscreen Editor</DialogTitle>
|
||||
<DialogDescription>Edit content in fullscreen mode</DialogDescription>
|
||||
<DialogTitle>
|
||||
<Trans comment="Screen reader title for the fullscreen rich-text editor dialog">
|
||||
Fullscreen Editor
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans comment="Screen reader description for the fullscreen rich-text editor dialog">
|
||||
Edit content in fullscreen mode
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{editorElement}
|
||||
</DialogContent>
|
||||
|
||||
@@ -69,16 +69,22 @@ export function URLInput({ value, onChange, hideLabelButton, ...props }: Props)
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
title={t({
|
||||
comment: "Tooltip for action button that opens URL label editor",
|
||||
message: "Add a label to the URL",
|
||||
})}
|
||||
>
|
||||
<TagIcon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent className="pt-3">
|
||||
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div role="presentation" className="grid gap-2" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Label htmlFor="url-label">
|
||||
<Trans>Label</Trans>
|
||||
<Trans comment="Short field label for custom display text associated with a URL">Label</Trans>
|
||||
</Label>
|
||||
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,11 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-label={t`Level ${level} of 5`}
|
||||
role="img"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for skill/proficiency level indicator, where level is current value out of 5",
|
||||
message: `Level ${level} of 5`,
|
||||
})}
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5",
|
||||
type === "progress-bar" && "gap-x-0",
|
||||
|
||||
@@ -37,14 +37,20 @@ export function useWebfonts(typography: z.infer<typeof typographySchema>) {
|
||||
|
||||
for (const { url, weight, style } of fontUrls) {
|
||||
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
|
||||
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
|
||||
if (!document.fonts.has(fontFace)) {
|
||||
try {
|
||||
document.fonts.add(await fontFace.load());
|
||||
} catch {
|
||||
// Fail open for printer/headless environments where remote fonts may be blocked by CSP.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyTypography = typography.body;
|
||||
const headingTypography = typography.heading;
|
||||
|
||||
void Promise.all([
|
||||
void Promise.allSettled([
|
||||
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
|
||||
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
|
||||
]).then(() => {
|
||||
|
||||
@@ -36,8 +36,29 @@ export type ExtendedIconProps = IconProps & {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
|
||||
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
|
||||
function scopeCustomCssSelectors(css: string): string {
|
||||
// keep @keyframes blocks unchanged, scope the remaining rule selectors
|
||||
const keyframes: string[] = [];
|
||||
const withoutKeyframes = css.replace(/@(-webkit-)?keyframes\s+[^{]+\{[\s\S]*?\}\s*\}/gi, (block) => {
|
||||
keyframes.push(block);
|
||||
return `__RR_KEYFRAMES_${keyframes.length - 1}__`;
|
||||
});
|
||||
|
||||
const scoped = withoutKeyframes.replace(/(^|})\s*([^@{}][^{]+)\{/g, (_match, prefix, rawSelectors) => {
|
||||
const selectors = rawSelectors
|
||||
.split(",")
|
||||
.map((selector: string) => selector.trim())
|
||||
.filter(Boolean)
|
||||
.map((selector: string) => `.resume-preview-container ${selector}`)
|
||||
.join(", ");
|
||||
if (!selectors) return `${prefix}${rawSelectors}{`;
|
||||
return `${prefix} ${selectors}{`;
|
||||
});
|
||||
|
||||
const restored = scoped.replace(/__RR_KEYFRAMES_(\d+)__/g, (_match, index) => keyframes[Number(index)] ?? "");
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
function getTemplateComponent(template: Template) {
|
||||
return match(template)
|
||||
@@ -82,24 +103,7 @@ export const ResumePreview = ({ showPageNumbers = false, pageClassName, classNam
|
||||
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
|
||||
|
||||
const sanitizedCss = sanitizeCss(metadata.css.value);
|
||||
|
||||
const scoped = sanitizedCss
|
||||
.split(CSS_RULE_SPLIT_PATTERN)
|
||||
.map((rule) => {
|
||||
const trimmed = rule.trim();
|
||||
if (!trimmed || trimmed.startsWith("@")) return trimmed;
|
||||
|
||||
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
|
||||
const prefixed = selectors
|
||||
.split(",")
|
||||
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
|
||||
.join(", ");
|
||||
return `${prefixed}${brace}`;
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return scoped;
|
||||
return scopeCustomCssSelectors(sanitizedCss);
|
||||
}, [metadata.css.enabled, metadata.css.value]);
|
||||
|
||||
return (
|
||||
@@ -134,10 +138,10 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
|
||||
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
|
||||
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
|
||||
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
const pageNumber = pageIndex + 1;
|
||||
const maxPageHeight = pageDimensionsAsPixels[metadata.page.format].height;
|
||||
const totalNumberOfPages = metadata.layout.pages.length;
|
||||
const TemplateComponent = getTemplateComponent(metadata.template);
|
||||
|
||||
useResizeObserver({
|
||||
ref: pageRef as RefObject<HTMLDivElement>,
|
||||
@@ -152,7 +156,7 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute inset-s-0 -top-6 print:hidden">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<Trans>
|
||||
<Trans comment="Page counter label shown above resume preview pages">
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
@@ -174,12 +178,14 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
|
||||
<Alert className="max-w-sm text-yellow-600">
|
||||
<WarningIcon color="currentColor" />
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
<Trans comment="Warning shown when resume content exceeds printable page height">
|
||||
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
|
||||
<Trans>Learn more about how to fit content on a page</Trans>
|
||||
<Trans comment="Help link text to documentation about fitting resume content onto a page">
|
||||
Learn more about how to fit content on a page
|
||||
</Trans>
|
||||
<ArrowRightIcon color="currentColor" className="ms-1 inline size-3" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -10,7 +10,12 @@ export function Copyright({ className, ...props }: Props) {
|
||||
<p>
|
||||
<Trans>
|
||||
Licensed under{" "}
|
||||
<a href="#" target="_blank" rel="noopener" className="font-medium underline underline-offset-2">
|
||||
<a
|
||||
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
.
|
||||
@@ -18,7 +23,7 @@ export function Copyright({ className, ...props }: Props) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
<Trans comment="Tagline shown in app footer/about area">By the community, for the community.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -36,7 +41,11 @@ export function Copyright({ className, ...props }: Props) {
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4">Reactive Resume v{__APP_VERSION__}</p>
|
||||
<p className="mt-4">
|
||||
<Trans comment="App version label in footer; includes semantic version variable">
|
||||
Reactive Resume v{__APP_VERSION__}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ function InputGroupAddon({
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
onMouseDown={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -2,16 +2,19 @@ import type * as React from "react";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
function Label({ className, children, htmlFor, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { SidebarIcon } from "@phosphor-icons/react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
@@ -176,8 +178,12 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>
|
||||
<Trans comment="Dialog title for the mobile navigation sidebar panel">Sidebar</Trans>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<Trans comment="Dialog description for the mobile sidebar panel">Displays the mobile sidebar.</Trans>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
@@ -210,7 +216,7 @@ function Sidebar({
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:-left-(--sidebar-width) data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:-right-(--sidebar-width) md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+2px)]"
|
||||
@@ -248,7 +254,9 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
|
||||
{...props}
|
||||
>
|
||||
<SidebarIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader-only label for button that opens or closes the app sidebar">Toggle Sidebar</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -260,10 +268,16 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for draggable/clickable sidebar rail that toggles sidebar visibility",
|
||||
message: "Toggle Sidebar",
|
||||
})}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
title={t({
|
||||
comment: "Tooltip text for sidebar rail toggle action",
|
||||
message: "Toggle Sidebar",
|
||||
})}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:inset-s-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { SpinnerIcon } from "@phosphor-icons/react";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return <SpinnerIcon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />;
|
||||
return (
|
||||
<SpinnerIcon
|
||||
role="status"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for loading spinner icon",
|
||||
message: "Loading",
|
||||
})}
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/locale";
|
||||
import { isTheme } from "@/utils/theme";
|
||||
|
||||
@@ -56,7 +57,16 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
void router.invalidate();
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when signing out fails",
|
||||
message: "Failed to sign out. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -73,7 +83,7 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<TranslateIcon />
|
||||
<Trans>Language</Trans>
|
||||
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
|
||||
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
|
||||
@@ -89,15 +99,15 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PaletteIcon />
|
||||
<Trans>Theme</Trans>
|
||||
<Trans comment="Menu item that opens appearance theme selection submenu">Theme</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Trans>Light</Trans>
|
||||
<Trans comment="Appearance theme option for light mode">Light</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Trans>Dark</Trans>
|
||||
<Trans comment="Appearance theme option for dark mode">Dark</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
@@ -108,7 +118,7 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<SignOutIcon />
|
||||
<Trans>Logout</Trans>
|
||||
<Trans comment="User menu action to sign out of current account">Logout</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
@@ -59,7 +60,16 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when creating an API key fails",
|
||||
message: "Failed to create API key. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,7 +159,7 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Create</Trans>
|
||||
<Trans comment="Create API key dialog submit action">Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -206,7 +216,7 @@ const CopyApiKeyForm = ({ apiKey }: CopyApiKeyFormProps) => {
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm}>
|
||||
<Trans>Confirm</Trans>
|
||||
<Trans comment="Create API key dialog acknowledgment action after copying">Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
@@ -55,7 +56,16 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when changing account password fails",
|
||||
message: "Failed to update your password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,6 +110,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
|
||||
<span className="sr-only">
|
||||
{showCurrentPassword
|
||||
? t({
|
||||
comment: "Accessible label for toggle button that hides the visible current password",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for toggle button that reveals the masked current password",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -130,6 +151,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
|
||||
<span className="sr-only">
|
||||
{showNewPassword
|
||||
? t({
|
||||
comment: "Accessible label for toggle button that hides the visible new password",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for toggle button that reveals the masked new password",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -140,7 +172,7 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Update Password</Trans>
|
||||
<Trans comment="Primary action button to submit changed password">Update Password</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
@@ -43,7 +44,16 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
|
||||
const { error } = await authClient.twoFactor.disable({ password: data.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when disabling two-factor authentication fails",
|
||||
message: "Failed to disable two-factor authentication. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +102,19 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
<span className="sr-only">
|
||||
{showPassword
|
||||
? t({
|
||||
comment:
|
||||
"Accessible label for toggle button that hides the visible password in two-factor disable dialog",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment:
|
||||
"Accessible label for toggle button that reveals the masked password in two-factor disable dialog",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -102,7 +125,7 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trans>Disable 2FA</Trans>
|
||||
<Trans comment="Destructive action button to turn off two-factor authentication">Disable 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
@@ -76,7 +77,16 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when enabling two-factor authentication fails",
|
||||
message: "Failed to enable two-factor authentication. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +106,16 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code: data.code });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when verifying two-factor setup code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,6 +221,19 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
<span className="sr-only">
|
||||
{showPassword
|
||||
? t({
|
||||
comment:
|
||||
"Accessible label for toggle button that hides the visible password in two-factor setup",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment:
|
||||
"Accessible label for toggle button that reveals the masked password in two-factor setup",
|
||||
message: "Show password",
|
||||
})}
|
||||
</span>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -264,10 +296,12 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
|
||||
<DialogFooter className="gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
<Trans comment="Secondary action button to close two-factor setup dialog">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
<Trans comment="Primary action button to proceed to next step in two-factor setup">
|
||||
Continue
|
||||
</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -290,11 +324,11 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={handleDownloadBackupCodes} className="flex-1">
|
||||
<ArrowDownIcon className="me-2 size-4" />
|
||||
<Trans>Download</Trans>
|
||||
<Trans comment="Action button to download two-factor backup codes as a text file">Download</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleCopyBackupCodes} className="flex-1">
|
||||
<CopyIcon className="me-2 size-4" />
|
||||
<Trans>Copy</Trans>
|
||||
<Trans comment="Action button to copy two-factor backup codes to clipboard">Copy</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,7 +336,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onConfirmBackup}>
|
||||
<Trans>Continue</Trans>
|
||||
<Trans comment="Final action button after saving backup codes">Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
@@ -328,7 +362,10 @@ function TwoFactorQRCode({ totpUri }: { totpUri: string }) {
|
||||
size={256}
|
||||
marginSize={2}
|
||||
className="rounded-md"
|
||||
title="Two-Factor Authentication QR Code"
|
||||
title={t({
|
||||
comment: "Accessible title for QR code image shown during two-factor setup",
|
||||
message: "Two-Factor Authentication QR Code",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { JSONResumeImporter } from "@/integrations/import/json-resume";
|
||||
import { ReactiveResumeJSONImporter } from "@/integrations/import/reactive-resume-json";
|
||||
import { ReactiveResumeV4JSONImporter } from "@/integrations/import/reactive-resume-v4-json";
|
||||
import { client, orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
@@ -186,18 +187,39 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) throw new Error("No data was returned from the AI provider.");
|
||||
if (!data) {
|
||||
throw new Error(
|
||||
t({
|
||||
comment: "Error shown when AI import endpoint returns no parsed resume data",
|
||||
message: "No data was returned from the AI provider.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const id = await importResume({ data });
|
||||
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
|
||||
closeDialog();
|
||||
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId, description: null });
|
||||
} else {
|
||||
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
|
||||
}
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error shown when AI parsing returns invalid resume structure during import",
|
||||
message: "The imported file could not be parsed into a valid resume.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when AI provider is unreachable during PDF/DOCX resume import",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when importing a resume fails for an unknown reason",
|
||||
message: "An unknown error occurred while importing your resume.",
|
||||
}),
|
||||
}),
|
||||
{ id: toastId, description: null },
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -235,14 +257,36 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
|
||||
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
|
||||
{ value: "json-resume-json", label: "JSON Resume" },
|
||||
{
|
||||
value: "reactive-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for current Reactive Resume JSON format",
|
||||
message: "Reactive Resume (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "reactive-resume-v4-json",
|
||||
label: t({
|
||||
comment: "Import source option for legacy Reactive Resume v4 JSON format",
|
||||
message: "Reactive Resume v4 (JSON)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "json-resume-json",
|
||||
label: t({
|
||||
comment: "Import source option for standard JSON Resume format",
|
||||
message: "JSON Resume",
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
PDF <Badge>{t`AI`}</Badge>
|
||||
{t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "PDF",
|
||||
})}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -250,7 +294,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
value: "docx",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Microsoft Word <Badge>{t`AI`}</Badge>
|
||||
{t({
|
||||
comment: "File format label in import source selector",
|
||||
message: "Microsoft Word",
|
||||
})}{" "}
|
||||
<Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc, type RouterInput } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
import { generateId, generateRandomName, slugify } from "@/utils/string";
|
||||
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
@@ -70,12 +71,7 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -99,7 +95,7 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -121,7 +117,13 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
<ResumeForm />
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonGroup aria-label="Create Resume with Options" className="gap-x-px rtl:flex-row-reverse">
|
||||
<ButtonGroup
|
||||
aria-label={t({
|
||||
comment: "Accessible label for create-resume split button group",
|
||||
message: "Create resume with options",
|
||||
})}
|
||||
className="gap-x-px rtl:flex-row-reverse"
|
||||
>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
@@ -183,12 +185,7 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -257,7 +254,7 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
|
||||
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl } from "@/utils/file";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: ResumeData };
|
||||
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params: { username, slug } }) => {
|
||||
loader: async ({ context, params, ...rest }) => {
|
||||
console.log("$username/$slug loader", JSON.stringify({ params, context, rest }, null, 2));
|
||||
|
||||
const { username, slug } = params;
|
||||
const resume = await context.queryClient.ensureQueryData(
|
||||
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
|
||||
);
|
||||
@@ -28,7 +27,19 @@ export const Route = createFileRoute("/$username/$slug")({
|
||||
return { resume: resume as LoaderData };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [{ title: loaderData ? `${loaderData.resume.name} - Reactive Resume` : "Reactive Resume" }],
|
||||
meta: [
|
||||
{
|
||||
title: loaderData
|
||||
? `${loaderData.resume.name} - ${t({
|
||||
comment: "Brand name suffix in browser tab title for public resume pages",
|
||||
message: "Reactive Resume",
|
||||
})}`
|
||||
: t({
|
||||
comment: "Browser tab title before the public resume finishes loading",
|
||||
message: "Reactive Resume",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "NEED_PASSWORD") {
|
||||
@@ -54,9 +65,6 @@ function RouteComponent() {
|
||||
const initialize = useResumeStore((state) => state.initialize);
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
const { mutateAsync: printResumeAsPDF, isPending: isPrinting } = useMutation(
|
||||
orpc.printer.printResumeAsPDF.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resume) return;
|
||||
@@ -64,12 +72,6 @@ function RouteComponent() {
|
||||
return () => initialize(null);
|
||||
}, [resume, initialize]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const { url } = await printResumeAsPDF({ id: resume.id });
|
||||
await downloadFromUrl(url, `${resume.name}.pdf`);
|
||||
}, [resume, printResumeAsPDF]);
|
||||
|
||||
if (!isReady) return <LoadingScreen />;
|
||||
|
||||
return (
|
||||
@@ -79,17 +81,6 @@ function RouteComponent() {
|
||||
>
|
||||
<ResumePreview className="space-y-4" pageClassName="print:w-full! w-full max-w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isPrinting}
|
||||
className="fixed inset-e-4 bottom-4 z-50 hidden rounded-full px-4 md:inline-flex print:hidden"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{isPrinting ? <Spinner /> : <DownloadSimpleIcon />}
|
||||
{isPrinting ? <Trans>Downloading...</Trans> : <Trans>Download</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ const getFaqItems = (): FAQItemData[] => [
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
<span className="sr-only"> (opens in new tab)</span>
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen reader hint indicating the FAQ translation contribution link opens in a new browser tab">
|
||||
{" "}
|
||||
(opens in new tab)
|
||||
</Trans>
|
||||
</span>
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
@@ -78,7 +83,7 @@ export function FAQ() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<Trans context="Home page FAQ section heading with each word visually separated into individual spans">
|
||||
<span>Frequently</span>
|
||||
<span>Asked</span>
|
||||
<span>Questions</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { RouterOutput } from "@/integrations/orpc/client";
|
||||
@@ -51,7 +50,6 @@ type SocialAuthButtonsProps = {
|
||||
|
||||
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
@@ -62,7 +60,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when social sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +84,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when custom OAuth sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +105,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: false });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when passkey sign-in fails without an error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,25 +120,6 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
@@ -137,7 +137,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
className={cn("hidden", "passkey" in providers && "inline-flex")}
|
||||
>
|
||||
<FingerprintIcon />
|
||||
Passkey
|
||||
<Trans comment="Label for passkey sign-in button">Passkey</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -148,7 +148,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
Google
|
||||
<Trans comment="Brand name label for Google social sign-in button">Google</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -159,7 +159,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
GitHub
|
||||
<Trans comment="Brand name label for GitHub social sign-in button">GitHub</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -170,7 +170,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
LinkedIn
|
||||
<Trans comment="Brand name label for LinkedIn social sign-in button">LinkedIn</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +78,8 @@ function RouteComponent() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -87,10 +95,20 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={<Input type="email" autoComplete="email" placeholder="john.doe@example.com" {...field} />}
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -98,7 +116,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Send Password Reset Email</Trans>
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -122,7 +140,7 @@ function PostForgotPasswordScreen() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans>Open Email Client</Trans>
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
+66
-11
@@ -2,7 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
@@ -33,9 +36,13 @@ type FormValues = z.infer<typeof formSchema>;
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -55,7 +62,14 @@ function RouteComponent() {
|
||||
: await authClient.signIn.username({ username: data.identifier, password: data.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, { id: toastId });
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,11 +95,30 @@ function RouteComponent() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!flags.disableSignups && (
|
||||
@@ -98,7 +131,10 @@ function RouteComponent() {
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
Create one now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -116,14 +152,18 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">
|
||||
Email Address
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -144,7 +184,7 @@ function RouteComponent() {
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
@@ -154,7 +194,7 @@ function RouteComponent() {
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Forgot Password?</Trans>
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -172,7 +212,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +237,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign in</Trans>
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -71,7 +71,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,7 +104,8 @@ function RouteComponent() {
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -114,11 +122,20 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input min={3} max={64} autoComplete="section-register name" placeholder="John Doe" {...field} />
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
@@ -132,7 +149,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
@@ -140,7 +157,10 @@ function RouteComponent() {
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder="john.doe"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -157,14 +177,17 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -181,7 +204,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -196,7 +219,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -206,7 +244,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign up</Trans>
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -242,7 +280,8 @@ function PostSignupScreen() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans>Continue</Trans> <ArrowRightIcon />
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -53,7 +53,14 @@ function RouteComponent() {
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: data.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +91,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -99,7 +106,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -109,7 +131,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Reset Password</Trans>
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z
|
||||
@@ -75,7 +76,16 @@ function RouteComponent() {
|
||||
toast.dismiss(toastId);
|
||||
form.setError("password", { message: t`The password you entered is incorrect` });
|
||||
} else {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -102,7 +112,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -117,7 +127,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -128,7 +153,7 @@ function RouteComponent() {
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans>Unlock</Trans>
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -43,7 +43,14 @@ function RouteComponent() {
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,14 +93,14 @@ function RouteComponent() {
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -44,7 +44,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,14 +102,14 @@ function RouteComponent() {
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Back to Login</Trans>
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -113,7 +120,9 @@ function RouteComponent() {
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans>Lost access to your authenticator?</Trans>
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
@@ -38,12 +39,21 @@ export function BuilderHeader() {
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
|
||||
Toggle left sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for button navigating from builder to resumes dashboard",
|
||||
message: "Go to resumes dashboard",
|
||||
})}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
@@ -59,6 +69,11 @@ export function BuilderHeader() {
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
|
||||
Toggle right sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -99,7 +114,7 @@ function BuilderHeaderDropdown() {
|
||||
{ id, isLocked: !isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -122,7 +137,7 @@ function BuilderHeaderDropdown() {
|
||||
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type z from "zod";
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
@@ -119,7 +120,10 @@ export function CustomFieldsSection({ onSubmit }: Props) {
|
||||
type="url"
|
||||
value={field.value}
|
||||
id={`customFields.${index}.link`}
|
||||
placeholder="Must start with https://"
|
||||
placeholder={t({
|
||||
comment: "Placeholder text for custom link URL field in resume builder",
|
||||
message: "Must start with https://",
|
||||
})}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
@@ -46,9 +47,18 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
|
||||
.with("summary", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || "Summary";
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is empty",
|
||||
message: "Summary",
|
||||
});
|
||||
}
|
||||
return "Summary";
|
||||
return t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
|
||||
message: "Summary",
|
||||
});
|
||||
})
|
||||
.with("profiles", () => ("network" in item ? item.network : ""))
|
||||
.with("experience", () => ("company" in item ? item.company : ""))
|
||||
@@ -65,9 +75,18 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
|
||||
.with("cover-letter", () => {
|
||||
if ("recipient" in item) {
|
||||
const stripped = stripHtml(item.recipient);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || "Cover Letter";
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
}
|
||||
return "Cover Letter";
|
||||
return t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -216,9 +235,15 @@ function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
};
|
||||
|
||||
const onDeleteSection = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { pictureSchema } from "@/schema/resume/data";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -59,9 +60,10 @@ function PictureSectionForm() {
|
||||
if (!picture.url) return;
|
||||
|
||||
const appOrigin = window.location.origin;
|
||||
const pictureOrigin = new URL(picture.url).origin;
|
||||
const pictureUrl = new URL(picture.url, appOrigin);
|
||||
const pictureOrigin = pictureUrl.origin;
|
||||
|
||||
const filename = picture.url.split("/").pop();
|
||||
const filename = pictureUrl.pathname.split("/").pop();
|
||||
if (!filename) return;
|
||||
|
||||
// If the picture is from the same origin, attempt to delete it
|
||||
@@ -85,7 +87,16 @@ function PictureSectionForm() {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when uploading profile picture for resume fails",
|
||||
message: "Failed to upload picture. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,8 +107,10 @@ function PictureSectionForm() {
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={picture.url ? onDeletePicture : onSelectPicture}
|
||||
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
|
||||
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
{picture.url && (
|
||||
@@ -111,7 +124,7 @@ function PictureSectionForm() {
|
||||
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
|
||||
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -122,7 +135,7 @@ function PictureSectionForm() {
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormControl render={<Input {...field} readOnly />} />
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -235,7 +248,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Square`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to square",
|
||||
message: "Square",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(1);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
@@ -246,7 +262,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Landscape`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to landscape orientation",
|
||||
message: "Landscape",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(1.5);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
@@ -257,7 +276,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Portrait`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to portrait orientation",
|
||||
message: "Portrait",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(0.5);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
@@ -210,9 +211,15 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this item?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a section item in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -88,10 +88,16 @@ export function SectionDropdownMenu({ type }: Props) {
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to reset this section?", {
|
||||
description: "This will remove all items from this section.",
|
||||
confirmText: "Reset",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
|
||||
description: t`This will remove all items from this section.`,
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when resetting a resume section",
|
||||
message: "Reset",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort resetting a resume section",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { type CSSProperties, forwardRef, type HTMLAttributes, useCallback, useState } from "react";
|
||||
import { type CSSProperties, forwardRef, type HTMLAttributes, useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
@@ -30,8 +30,18 @@ type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () => t`Main`)
|
||||
.with("sidebar", () => t`Sidebar`)
|
||||
.with("main", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the primary content area",
|
||||
message: "Main",
|
||||
}),
|
||||
)
|
||||
.with("sidebar", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the secondary sidebar area",
|
||||
message: "Sidebar",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -257,20 +267,25 @@ function PageContainer({
|
||||
onToggleFullWidth,
|
||||
}: PageContainerProps) {
|
||||
const isFullWidth = page.fullWidth;
|
||||
const fullWidthSwitchId = useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="text-xs font-medium">
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Switch checked={page.fullWidth} onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)} />
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Full Width</Trans>
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -24,6 +25,29 @@ function impactCircleClass(impact: "high" | "medium" | "low") {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function impactLabel(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "High",
|
||||
}),
|
||||
)
|
||||
.with("medium", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Medium",
|
||||
}),
|
||||
)
|
||||
.with("low", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Low",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function ResumeAnalysisSectionBuilder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -43,7 +67,24 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
toast.success(t`Resume analysis complete.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to analyze resume.`, { description: error.message });
|
||||
toast.error(t`Failed to analyze resume.`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI returns invalid resume analysis format",
|
||||
message: "The AI returned an invalid analysis format. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider cannot be reached during resume analysis",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when resume analysis request fails",
|
||||
message: "Something went wrong while analyzing your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,9 +220,10 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
<div key={`${suggestion.title}-${index}`} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={suggestion.impact}
|
||||
aria-label={suggestion.impact}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<div className="text-sm font-semibold tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,9 @@ export function SharingSectionBuilder() {
|
||||
{resume.isPublic && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sharing-url">URL</Label>
|
||||
<Label htmlFor="sharing-url">
|
||||
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
@@ -106,17 +106,27 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
disabled={!hasApplyLink}
|
||||
render={
|
||||
<a href={hasApplyLink ? job.job_apply_link : "#"} target="_blank" rel="noopener noreferrer" />
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
{hasApplyLink ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href={job.job_apply_link} target="_blank" rel="noopener noreferrer">
|
||||
<span className="sr-only">
|
||||
<Trans>Apply for this job</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" disabled>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="flex-1" onClick={() => setTailorOpen(true)}>
|
||||
<StarIcon />
|
||||
@@ -211,7 +221,11 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.publisher ||
|
||||
t({
|
||||
comment: "Fallback publisher name for a third-party job application link",
|
||||
message: "Apply Link",
|
||||
})}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { plural, t } from "@lingui/core/macro";
|
||||
|
||||
import type { RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
@@ -13,7 +13,7 @@ export function formatSalary(
|
||||
const formatCurrency = (amount: number) => {
|
||||
const resolvedCurrency = currency ?? "USD";
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: resolvedCurrency,
|
||||
maximumFractionDigits: 0,
|
||||
@@ -23,10 +23,15 @@ export function formatSalary(
|
||||
}
|
||||
};
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
if (min) return `${formatCurrency(min)}+${period ? ` / ${period}` : ""}`;
|
||||
const periodSuffix = period ? ` / ${period}` : "";
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${periodSuffix}`;
|
||||
if (min) return `${formatCurrency(min)}+${periodSuffix}`;
|
||||
if (!max) return "";
|
||||
return `Up to ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
return t({
|
||||
comment: "Salary label when only an upper salary bound is available",
|
||||
message: `Up to ${formatCurrency(max)}${periodSuffix}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPostedDate(timestamp: number | null): string {
|
||||
@@ -38,9 +43,26 @@ export function formatPostedDate(timestamp: number | null): string {
|
||||
|
||||
if (diffDays <= 0) return t`Today`;
|
||||
if (diffDays === 1) return t`Yesterday`;
|
||||
if (diffDays < 7) return t`${diffDays} days ago`;
|
||||
if (diffDays < 30) return t`${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return t`${Math.floor(diffDays / 30)} months ago`;
|
||||
if (diffDays < 7) {
|
||||
return plural(diffDays, {
|
||||
one: "# day ago",
|
||||
other: "# days ago",
|
||||
});
|
||||
}
|
||||
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return plural(weeks, {
|
||||
one: "# week ago",
|
||||
other: "# weeks ago",
|
||||
});
|
||||
}
|
||||
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return plural(months, {
|
||||
one: "# month ago",
|
||||
other: "# months ago",
|
||||
});
|
||||
}
|
||||
|
||||
export function getQuotaStatus(quota: RapidApiQuota): "healthy" | "warning" | "critical" {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { client, orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { buildSkillSyncOperations, tailorOutputToPatches, validateTailorOutput } from "@/utils/resume/tailor";
|
||||
import { slugify } from "@/utils/string";
|
||||
|
||||
@@ -77,7 +78,20 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
{
|
||||
onSuccess: (newResumeId) => navigateToBuilder(newResumeId),
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to duplicate resume`, { description: error.message });
|
||||
toast.error(t`Failed to duplicate resume`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
RESUME_SLUG_ALREADY_EXISTS: t({
|
||||
comment: "Error description when generated slug for duplicated tailored resume already exists",
|
||||
message: "A resume with this slug already exists. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when duplicating a resume for tailoring fails",
|
||||
message: "Could not duplicate your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -138,8 +152,28 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
navigateToBuilder(newResumeId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Tailoring failed`, { description: message });
|
||||
toast.error(t`Tailoring failed`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI tailoring output is invalid",
|
||||
message: "The AI returned invalid tailoring data. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider is unreachable during resume tailoring",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
INVALID_PATCH_OPERATIONS: t({
|
||||
comment: "Error description when generated patch operations cannot be applied to tailored resume",
|
||||
message: "Generated resume changes were invalid. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when tailoring pipeline fails",
|
||||
message: "Something went wrong while tailoring your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
setPhase({ step: "select" });
|
||||
}
|
||||
};
|
||||
@@ -156,8 +190,21 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
await client.resume.patch({ id: sourceResumeId, operations });
|
||||
toast.success(t`Added ${skillsToSync.length} new skills to your original resume`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Failed to sync skills`, { description: message });
|
||||
toast.error(t`Failed to sync skills`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
INVALID_PATCH_OPERATIONS: t({
|
||||
comment:
|
||||
"Error description when syncing newly detected skills to source resume fails due to invalid patch",
|
||||
message: "Could not apply skill updates to your original resume.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when syncing newly detected skills fails",
|
||||
message: "Something went wrong while syncing skills.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -195,9 +242,9 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created
|
||||
{aiEnabled ? " and the AI will optimize it for this position." : "."}
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created.
|
||||
</Trans>
|
||||
{aiEnabled && <Trans>The AI will optimize it for this position.</Trans>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -315,7 +362,9 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleSkillSync}>
|
||||
<Trans>Save {selectedSkills.size} Skills</Trans>
|
||||
<Trans comment="Button label to save the selected number of detected skills to the original resume">
|
||||
Save {selectedSkills.size} Skills
|
||||
</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { JobResult, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
@@ -71,8 +72,21 @@ export function useJobSearch() {
|
||||
},
|
||||
onError: (error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(error.message);
|
||||
toast.error(error.message);
|
||||
const message = getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when job search API is unavailable while searching jobs",
|
||||
message: "Could not fetch jobs from JSearch API. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error shown when job search request fails",
|
||||
message: "Failed to search jobs. Please try again.",
|
||||
}),
|
||||
});
|
||||
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -96,11 +96,17 @@ function RouteComponent() {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`e.g. frontend developer jobs in Berlin`}
|
||||
placeholder={t({
|
||||
comment: "Example placeholder text in job search input field",
|
||||
message: "e.g. frontend developer jobs in Berlin",
|
||||
})}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for free-text job search query field",
|
||||
message: "Job search query",
|
||||
})}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
type Props = {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
@@ -56,7 +57,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
{ id: resume.id, isLocked: !resume.isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -78,7 +79,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -93,7 +94,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
render={
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<FolderOpenIcon />
|
||||
<Trans>Open</Trans>
|
||||
<Trans comment="Resume card context menu action to open the resume editor">Open</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -102,24 +103,28 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
|
||||
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
<Trans comment="Resume card context menu action to create a copy">Duplicate</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card context menu action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card context menu action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Resume card context menu destructive action to remove a resume">Delete</Trans>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
@@ -56,7 +57,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
{ id: resume.id, isLocked: !resume.isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -78,7 +79,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -92,7 +93,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<DropdownMenuItem>
|
||||
<FolderOpenIcon />
|
||||
<Trans>Open</Trans>
|
||||
<Trans comment="Resume card dropdown action to open the resume editor">Open</Trans>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
|
||||
@@ -100,24 +101,28 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
|
||||
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
<Trans comment="Resume card dropdown action to create a copy">Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card dropdown action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card dropdown action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Resume card dropdown destructive action to remove a resume">Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type AIProvider, useAIStore } from "@/integrations/ai/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
@@ -28,31 +29,46 @@ export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
const providerOptions: (ComboboxOption<AIProvider> & { defaultBaseURL: string })[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "OpenAI",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "OpenAI",
|
||||
}),
|
||||
keywords: ["openai", "gpt", "chatgpt"],
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: "Ollama",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Ollama",
|
||||
}),
|
||||
keywords: ["ollama", "ai", "local"],
|
||||
defaultBaseURL: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: "Anthropic Claude",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Anthropic Claude",
|
||||
}),
|
||||
keywords: ["anthropic", "claude", "ai"],
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
},
|
||||
{
|
||||
value: "vercel-ai-gateway",
|
||||
label: "Vercel AI Gateway",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Vercel AI Gateway",
|
||||
}),
|
||||
keywords: ["vercel", "gateway", "ai"],
|
||||
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: "Google Gemini",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Google Gemini",
|
||||
}),
|
||||
keywords: ["gemini", "google", "bard"],
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
@@ -106,7 +122,24 @@ function AIForm() {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
|
||||
message: "Invalid AI provider configuration. Please check your settings.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when the configured AI provider cannot be reached during connection test",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing AI provider connection fails",
|
||||
message: "Failed to test AI provider connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -138,7 +171,10 @@ function AIForm() {
|
||||
value={model}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
placeholder="e.g., gpt-4, claude-3-opus, gemini-pro"
|
||||
placeholder={t({
|
||||
comment: "Example model-name placeholder in AI settings",
|
||||
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
|
||||
})}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -38,8 +39,14 @@ function RouteComponent() {
|
||||
const onDelete = async (id: string) => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this API key?`, {
|
||||
description: t`The API key will no longer be able to access your data after deletion. This action cannot be undone.`,
|
||||
confirmText: t`Delete`,
|
||||
cancelText: t`Cancel`,
|
||||
confirmText: t({
|
||||
comment: "API key deletion confirmation dialog confirm action in settings",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "API key deletion confirmation dialog cancel action in settings",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
@@ -49,7 +56,16 @@ function RouteComponent() {
|
||||
const { error } = await authClient.apiKey.delete({ keyId: id });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting an API key fails",
|
||||
message: "Failed to delete the API key. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,18 +18,49 @@ import type { AuthProvider } from "@/integrations/auth/types";
|
||||
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
/**
|
||||
* Get the display name for a social provider
|
||||
*/
|
||||
export function getProviderName(providerId: AuthProvider): string {
|
||||
return match(providerId)
|
||||
.with("credential", () => "Password")
|
||||
.with("passkey", () => "Passkey")
|
||||
.with("google", () => "Google")
|
||||
.with("github", () => "GitHub")
|
||||
.with("linkedin", () => "LinkedIn")
|
||||
.with("custom", () => "Custom OAuth")
|
||||
.with("credential", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Password",
|
||||
}),
|
||||
)
|
||||
.with("passkey", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Passkey",
|
||||
}),
|
||||
)
|
||||
.with("google", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Google",
|
||||
}),
|
||||
)
|
||||
.with("github", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "GitHub",
|
||||
}),
|
||||
)
|
||||
.with("linkedin", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "LinkedIn",
|
||||
}),
|
||||
)
|
||||
.with("custom", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Custom OAuth",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@@ -85,7 +116,16 @@ export function useAuthProviderActions() {
|
||||
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/settings/authentication" });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when linking a social authentication provider fails",
|
||||
message: "Failed to link provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,7 +139,16 @@ export function useAuthProviderActions() {
|
||||
const { error } = await authClient.unlinkAccount({ providerId: provider, accountId });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when unlinking a social authentication provider fails",
|
||||
message: "Failed to unlink provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
export function PasskeysSection() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -26,7 +27,15 @@ export function PasskeysSection() {
|
||||
},
|
||||
onSuccess: async ({ data, error }) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when passkey registration fails",
|
||||
message: "Failed to register passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +45,10 @@ export function PasskeysSection() {
|
||||
const name = await prompt(t`Enter a name for your passkey.`, {
|
||||
description: t`This will help you identify it later, if you plan to have multiple passkeys.`,
|
||||
defaultValue: "",
|
||||
confirmText: t`Save`,
|
||||
confirmText: t({
|
||||
comment: "Passkey rename prompt confirm action in authentication settings",
|
||||
message: "Save",
|
||||
}),
|
||||
});
|
||||
if (name === null) return;
|
||||
|
||||
@@ -46,7 +58,15 @@ export function PasskeysSection() {
|
||||
|
||||
const { error: renameError } = await authClient.passkey.updatePasskey({ id: passkeyId, name: passkeyName });
|
||||
if (renameError) {
|
||||
toast.error(renameError.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
renameError,
|
||||
t({
|
||||
comment: "Fallback toast when renaming a passkey fails",
|
||||
message: "Failed to rename passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,7 +83,15 @@ export function PasskeysSection() {
|
||||
},
|
||||
onSuccess: async ({ error }) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting a passkey fails",
|
||||
message: "Failed to delete passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,7 +159,7 @@ export function PasskeysSection() {
|
||||
disabled={deletePasskeyMutation.isPending}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Passkey row action to remove the selected passkey">Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,9 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
>
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
<Trans comment="Authentication settings action to unlink a connected social login provider">
|
||||
Disconnect
|
||||
</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
@@ -74,7 +76,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
>
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -33,8 +34,14 @@ function RouteComponent() {
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
|
||||
description: t`This action cannot be undone. All your data will be permanently deleted.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
confirmText: t({
|
||||
comment: "Account deletion confirmation dialog confirm action in danger zone",
|
||||
message: "Confirm",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Account deletion confirmation dialog cancel action in danger zone",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -48,7 +55,16 @@ function RouteComponent() {
|
||||
void navigate({ to: "/" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when account deletion fails",
|
||||
message: "Failed to delete your account. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -50,7 +51,20 @@ function RapidAPIKeyForm() {
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when JSearch API connection test fails in job search settings",
|
||||
message: "Could not reach JSearch API. Check your API key and try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback toast when testing RapidAPI job search connection fails",
|
||||
message: "Failed to test RapidAPI connection. Please try again.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -66,7 +67,15 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when updating profile details fails",
|
||||
message: "Failed to update your profile. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +90,15 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when requesting email change confirmation fails",
|
||||
message: "Failed to request email change. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,7 +119,16 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resending account verification email fails",
|
||||
message: "Failed to resend verification email. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +162,18 @@ function RouteComponent() {
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={<Input min={3} max={64} autoComplete="name" placeholder="John Doe" {...field} />}
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on profile settings form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -157,7 +194,10 @@ function RouteComponent() {
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="username"
|
||||
placeholder="john.doe"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on profile settings form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -181,7 +221,10 @@ function RouteComponent() {
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on profile settings form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -224,7 +267,7 @@ function RouteComponent() {
|
||||
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
|
||||
+36
-58
@@ -1,27 +1,18 @@
|
||||
import DOMPurify, { type Config } from "dompurify";
|
||||
|
||||
/**
|
||||
* DOMPurify configuration for sanitizing rich text content.
|
||||
* This configuration allows safe HTML tags used in the rich text editor
|
||||
* while stripping all potentially dangerous content like scripts and event handlers.
|
||||
*/
|
||||
const RICH_TEXT_CONFIG: Config = {
|
||||
// Allow safe HTML tags used by the TipTap editor
|
||||
ALLOWED_TAGS: [
|
||||
// Text formatting
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"span",
|
||||
"div",
|
||||
// Headings
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
// Text styling
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
@@ -32,11 +23,9 @@ const RICH_TEXT_CONFIG: Config = {
|
||||
"mark",
|
||||
"code",
|
||||
"pre",
|
||||
// Lists
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
// Tables
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
@@ -46,76 +35,65 @@ const RICH_TEXT_CONFIG: Config = {
|
||||
"td",
|
||||
"colgroup",
|
||||
"col",
|
||||
// Links
|
||||
"a",
|
||||
// Quotes
|
||||
"blockquote",
|
||||
],
|
||||
// Allow safe attributes
|
||||
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
|
||||
// Only allow http and https protocols in links
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
// Force all links to open in new tab with safe rel attributes
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
// Don't allow data: URIs which can be used for XSS
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks.
|
||||
* Uses DOMPurify with a strict configuration that only allows
|
||||
* safe HTML tags and attributes used by the rich text editor.
|
||||
*
|
||||
* @param html - The HTML string to sanitize
|
||||
* @returns Sanitized HTML string safe for rendering
|
||||
*/
|
||||
const PLAIN_TEXT_SANITIZE_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
};
|
||||
|
||||
function sanitizePlainTextContent(value: string): string {
|
||||
return DOMPurify.sanitize(value, PLAIN_TEXT_SANITIZE_CONFIG) as string;
|
||||
}
|
||||
|
||||
function stripCssComments(value: string): string {
|
||||
if (!value) return "";
|
||||
return value.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
}
|
||||
|
||||
function decodeCssEscapes(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|\\(.)/g, (_match, hex, escapedChar) => {
|
||||
if (hex) return String.fromCodePoint(parseInt(hex, 16));
|
||||
return escapedChar ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return "";
|
||||
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes CSS content to prevent CSS injection attacks.
|
||||
* Only allows CSS rules, stripping any JavaScript or HTML that might be embedded.
|
||||
*
|
||||
* Note: This is a basic sanitization. For more robust CSS sanitization,
|
||||
* consider using a dedicated CSS parser/sanitizer library.
|
||||
*/
|
||||
export function sanitizeCss(css: string): string {
|
||||
if (!css) return "";
|
||||
|
||||
// Remove any JavaScript expressions
|
||||
let sanitized = css
|
||||
// Remove javascript: URLs
|
||||
const normalized = decodeCssEscapes(stripCssComments(css));
|
||||
|
||||
const preSanitized = normalized
|
||||
.replace(/javascript\s*:/gi, "")
|
||||
// Remove expression() which can execute JS in older IE
|
||||
.replace(/expression\s*\(/gi, "")
|
||||
// Remove url() with data: or javascript:
|
||||
.replace(/url\s*\(\s*["']?\s*(?:javascript|data):/gi, "url(")
|
||||
// Remove behavior: property (IE-specific, can run scripts)
|
||||
.replace(/behavior\s*:/gi, "")
|
||||
// Remove -moz-binding (Firefox-specific, can run scripts)
|
||||
.replace(/-moz-binding\s*:/gi, "")
|
||||
// Remove @import with javascript or data URLs
|
||||
.replace(/@import\s+(?:url\s*\()?["']?\s*(?:javascript|data):/gi, "");
|
||||
.replace(/behavior\s*:[^;}]*/gi, "")
|
||||
.replace(/-moz-binding\s*:[^;}]*/gi, "");
|
||||
|
||||
// Use DOMPurify to clean any HTML that might be in the CSS
|
||||
sanitized = DOMPurify.sanitize(sanitized, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
}) as string;
|
||||
const sanitized = preSanitized
|
||||
.replace(/@import[^;]*;/gi, "")
|
||||
.replace(/@font-face\s*\{[^}]*\}/gi, "")
|
||||
.replace(/\b(?:url|image-set|cross-fade)\s*\([^)]*\)/gi, "")
|
||||
.replace(/^\s*src\s*:[^;]*;?/gim, "");
|
||||
|
||||
return sanitized;
|
||||
return sanitizePlainTextContent(sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given value is a plain JSON object (not null, not array, not other types).
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @returns True if the value is a plain object, false otherwise.
|
||||
*/
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user