mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 14:35:33 +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>
|
||||
|
||||
Reference in New Issue
Block a user