fix: release QA hardening across processing, media, security, and CI gates (#649)

A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
This commit is contained in:
SnapOtter
2026-07-27 15:37:30 +08:00
committed by GitHub
parent bc32f86a07
commit d10d0f544f
855 changed files with 54564 additions and 13092 deletions
+9 -7
View File
@@ -1,6 +1,6 @@
import { ANALYTICS_EVENTS, en } from "@snapotter/shared";
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router";
import { Toaster, toast } from "sonner";
import { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
@@ -185,12 +185,14 @@ export function App() {
<ConnectionMonitor />
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
<BrowserRouter>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium focus:shadow-lg"
>
{en.a11y.skipToContent}
</a>
<nav aria-label={en.a11y.skipToContent}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:start-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium focus:shadow-lg"
>
{en.a11y.skipToContent}
</a>
</nav>
<RouteAnnouncer />
<KeyboardShortcutProvider>
<AuthGuard>
@@ -1,7 +1,7 @@
import { ANALYTICS_EVENTS } from "@snapotter/shared";
import { AlertCircle, ArrowLeft, CheckCircle2, Download, FileText, FolderPlus } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { formatFileSize, triggerDownload } from "@/lib/download";
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
export function RouteAnnouncer() {
const location = useLocation();
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@snapotter/shared";
import { Clock, Download, FileImage, Loader2, Pin } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { ICON_MAP } from "@/lib/icon-map";
import { getToolDescription, getToolName } from "@/lib/tool-i18n";
@@ -1,7 +1,7 @@
import type { Tool } from "@snapotter/shared";
import { FileImage, FolderOpen } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
@@ -2,7 +2,7 @@
import { ArrowLeft, Check, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -1,6 +1,6 @@
// apps/web/src/components/editor/editor-right-panel.tsx
import { ChevronRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -34,6 +34,7 @@ export function EditorRightPanel() {
const setTab = useEditorStore((s) => s.setRightPanelTab);
const togglePanel = useEditorStore((s) => s.toggleRightPanel);
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [width, setWidth] = useState<number>(() => {
if (typeof window === "undefined") return DEFAULT_PANEL_WIDTH;
@@ -67,6 +68,31 @@ export function EditorRightPanel() {
[width],
);
const focusTab = useCallback(
(index: number) => {
const wrappedIndex = (index + TABS.length) % TABS.length;
setTab(TABS[wrappedIndex].id);
tabRefs.current[wrappedIndex]?.focus();
},
[setTab],
);
const handleTabKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let targetIndex: number | undefined;
if (event.key === "ArrowRight") targetIndex = index + 1;
else if (event.key === "ArrowLeft") targetIndex = index - 1;
else if (event.key === "Home") targetIndex = 0;
else if (event.key === "End") targetIndex = TABS.length - 1;
if (targetIndex === undefined) return;
event.preventDefault();
event.stopPropagation();
focusTab(targetIndex);
},
[focusTab],
);
if (!visible) {
return (
<button
@@ -98,25 +124,34 @@ export function EditorRightPanel() {
{sourceImageUrl && <NavigatorPanel />}
{/* Tabs */}
<div className="flex items-center border-b border-border" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => setTab(tab.id)}
className={cn(
"flex-1 py-2 text-xs font-medium text-center transition-colors",
activeTab === tab.id
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`tab-${tab.id}`}
>
{tab.label}
</button>
))}
<div className="flex items-center border-b border-border">
<div className="flex flex-1" role="tablist" aria-label={t.editor.menu.view.panels}>
{TABS.map((tab, index) => (
<button
key={tab.id}
ref={(element) => {
tabRefs.current[index] = element;
}}
type="button"
role="tab"
id={`editor-tab-${tab.id}`}
aria-controls="editor-panel"
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setTab(tab.id)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
className={cn(
"flex-1 py-2 text-xs font-medium text-center transition-colors",
activeTab === tab.id
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`tab-${tab.id}`}
>
{tab.label}
</button>
))}
</div>
<button
type="button"
onClick={togglePanel}
@@ -129,7 +164,12 @@ export function EditorRightPanel() {
{/* Tab content. `min-h-0` lets this flex child shrink so it scrolls
internally instead of pushing the color panel below the viewport. */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2">
<div
id="editor-panel"
role="tabpanel"
aria-labelledby={`editor-tab-${activeTab}`}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2"
>
{activeTab === "layers" && <LayersPanel />}
{activeTab === "adjustments" && <AdjustmentsPanel />}
{activeTab === "history" && <HistoryPanel />}
@@ -1,7 +1,10 @@
// apps/web/src/components/editor/editor-status-bar.tsx
import { useTranslation } from "@/contexts/i18n-context";
import { useEditorStore } from "@/stores/editor-store";
export function EditorStatusBar() {
const { t } = useTranslation();
const cursorPosition = useEditorStore((s) => s.cursorPosition);
const canvasSize = useEditorStore((s) => s.canvasSize);
const zoom = useEditorStore((s) => s.zoom);
@@ -25,6 +28,7 @@ export function EditorStatusBar() {
</div>
<div className="flex items-center gap-1" data-testid="status-zoom">
<input
aria-label={t.a11y.zoomControls}
type="number"
value={zoomPercent}
onChange={(e) => {
@@ -3,6 +3,7 @@
import {
ChevronDown,
ChevronRight,
ChevronUp,
Copy,
Eye,
EyeOff,
@@ -95,6 +96,7 @@ export function LayersPanel() {
const flattenAll = useEditorStore((s) => s.flattenAll);
const activeLayer = layers.find((l) => l.id === activeLayerId);
const activeLayerIndex = layers.findIndex((layer) => layer.id === activeLayerId);
// Context menu
const [contextMenu, setContextMenu] = useState<{
@@ -167,11 +169,7 @@ export function LayersPanel() {
</div>
{/* Layer list */}
<div
className="flex-1 overflow-y-auto py-1 min-h-0"
role="listbox"
aria-label={t.a11y.layers}
>
<ul className="flex-1 overflow-y-auto py-1 min-h-0" aria-label={t.a11y.layers}>
{displayLayers.map((layer) => {
const realIndex = layers.findIndex((l) => l.id === layer.id);
return (
@@ -190,7 +188,7 @@ export function LayersPanel() {
/>
);
})}
</div>
</ul>
{/* Layer effects section (only when an object is selected) */}
{selectedObject && (
@@ -211,6 +209,28 @@ export function LayersPanel() {
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => reorderLayers(activeLayerIndex, activeLayerIndex + 1)}
disabled={!activeLayer || activeLayerIndex >= layers.length - 1}
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t.automate.moveUp}
aria-label={`${t.automate.moveUp}: ${activeLayer?.name ?? ""}`}
data-testid="move-layer-up-btn"
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={() => reorderLayers(activeLayerIndex, activeLayerIndex - 1)}
disabled={!activeLayer || activeLayerIndex <= 0}
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t.automate.moveDown}
aria-label={`${t.automate.moveDown}: ${activeLayer?.name ?? ""}`}
data-testid="move-layer-down-btn"
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={() => {
@@ -346,7 +366,7 @@ function LayerRow({
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState(layer.name);
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLLIElement>(null);
// Drag reorder state
const dragState = useRef<{
@@ -466,7 +486,7 @@ function LayerRow({
);
return (
<div
<li
ref={rowRef}
className={cn(
"flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none group",
@@ -474,19 +494,11 @@ function LayerRow({
isActive && "bg-primary/10 border-s-2 border-primary",
!isActive && "border-s-2 border-transparent",
)}
role="option"
aria-selected={isActive}
aria-current={isActive ? "true" : undefined}
onPointerDown={handlePointerDown}
onContextMenu={onContextMenu}
data-testid={`layer-row-${layer.id}`}
data-layer-id={layer.id}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
>
{/* Visibility toggle */}
<button
@@ -538,6 +550,7 @@ function LayerRow({
ref={inputRef}
type="text"
value={editName}
aria-label={layer.name}
onChange={(e) => setEditName(e.target.value)}
onBlur={commitRename}
onKeyDown={handleKeyDown}
@@ -548,6 +561,7 @@ function LayerRow({
) : (
<button
type="button"
aria-pressed={isActive}
className={cn(
"block text-xs truncate text-start bg-transparent border-0 p-0 w-full cursor-pointer",
isActive ? "text-foreground font-medium" : "text-muted-foreground",
@@ -561,7 +575,7 @@ function LayerRow({
</button>
)}
</div>
</div>
</li>
);
}
+37 -10
View File
@@ -1,7 +1,7 @@
import { TOOLS } from "@snapotter/shared";
import { FileImage, FileText, ImageIcon, Music, Play, Video } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import {
apiGetFileDetails,
@@ -9,6 +9,7 @@ import {
getFileDownloadUrl,
getFilePreviewUrl,
getFileThumbnailUrl,
type UserFile,
type UserFileDetail,
} from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -371,13 +372,34 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function getFilesToOpen({
details,
files,
checkedIds,
filterMimePrefix,
}: {
details: UserFileDetail;
files: UserFile[];
checkedIds: Set<string>;
filterMimePrefix?: string;
}): UserFile[] {
const isCompatible = (file: Pick<UserFile, "mimeType">) =>
!filterMimePrefix || file.mimeType.startsWith(filterMimePrefix);
if (!isCompatible(details)) return [];
const compatibleChecked = files.filter((file) => checkedIds.has(file.id) && isCompatible(file));
if (compatibleChecked.length > 1) return compatibleChecked;
return [details];
}
interface FileDetailsProps {
filterMimePrefix?: string;
mobile?: boolean;
}
export function FileDetails({ mobile = false }: FileDetailsProps) {
export function FileDetails({ filterMimePrefix, mobile = false }: FileDetailsProps) {
const { t } = useTranslation();
const { selectedFileId } = useFilesPageStore();
const { files, selectedFileId } = useFilesPageStore();
const setFiles = useFileStore((s) => s.setFiles);
const navigate = useNavigate();
const location = useLocation();
@@ -385,9 +407,12 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
const [details, setDetails] = useState<UserFileDetail | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const selectedFile = files.find((file) => file.id === selectedFileId);
const selectedFileIsCompatible =
!filterMimePrefix || selectedFile?.mimeType.startsWith(filterMimePrefix) === true;
useEffect(() => {
if (!selectedFileId) {
if (!selectedFileId || !selectedFileIsCompatible) {
setDetails(null);
return;
}
@@ -396,7 +421,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
.then(setDetails)
.catch(() => setDetails(null))
.finally(() => setLoadingDetails(false));
}, [selectedFileId]);
}, [selectedFileId, selectedFileIsCompatible]);
async function handleOpenFile() {
if (!details) return;
@@ -404,10 +429,12 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
const { checkedIds, files: allFiles } = useFilesPageStore.getState();
// If multiple files are checked, open all of them; otherwise just the selected one
const filesToOpen =
checkedIds.size > 1
? allFiles.filter((f) => checkedIds.has(f.id))
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
const filesToOpen = getFilesToOpen({
details,
files: allFiles,
checkedIds,
filterMimePrefix,
});
const downloaded = await Promise.all(
filesToOpen.map(async (f) => {
@@ -445,7 +472,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
}, 0);
}
if (!selectedFileId) {
if (!selectedFileId || !selectedFileIsCompatible) {
return (
<div
className={cn(
@@ -1,4 +1,6 @@
import { TOOLS } from "@snapotter/shared";
import { useId } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import type { UserFile } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useFilesPageStore } from "@/stores/files-page-store";
@@ -23,78 +25,121 @@ function toolName(toolId: string): string {
interface FileListItemProps {
file: UserFile;
index: number;
disabled?: boolean;
tabStop: boolean;
onActivate?: (fileId: string) => void;
onNavigate: (index: number, key: FileNavigationKey) => void;
}
export function FileListItem({ file, disabled }: FileListItemProps) {
export type FileNavigationKey = "ArrowDown" | "ArrowUp" | "Home" | "End";
export function FileListItem({
file,
index,
disabled,
tabStop,
onActivate,
onNavigate,
}: FileListItemProps) {
const { t } = useTranslation();
const { selectedFileId, checkedIds, selectFile, toggleChecked } = useFilesPageStore();
const isSelected = selectedFileId === file.id;
const isChecked = checkedIds.has(file.id);
const fileNameId = useId();
const fileDescriptionId = useId();
return (
<div
<li
data-file-id={file.id}
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
tabIndex={-1}
onClick={() => {
if (!disabled) selectFile(file.id);
}}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) selectFile(file.id);
}}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors border",
"flex items-center rounded-lg transition-colors border",
disabled
? "opacity-40 cursor-not-allowed border-transparent"
? "border-transparent"
: isSelected
? "bg-primary/10 border-primary/30 cursor-pointer"
: "hover:bg-muted border-transparent cursor-pointer",
? "bg-primary/10 border-primary/30"
: "hover:bg-muted border-transparent",
)}
>
{/* Checkbox */}
<input
type="checkbox"
aria-label={`${t.files.selectFile}: ${file.originalName}`}
checked={isChecked}
onChange={() => toggleChecked(file.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0 accent-primary"
className="h-4 w-4 shrink-0 ms-3 accent-primary"
/>
{/* File name */}
<span className="flex-1 min-w-0 text-sm font-medium text-foreground truncate">
{file.originalName}
</span>
{/* Tool chain */}
{file.toolChain.length > 0 && (
<span className="hidden md:block text-xs text-primary-ink shrink-0">
{file.toolChain.map(toolName).join(" → ")}
</span>
)}
{/* Version badge */}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium shrink-0",
file.version >= 2
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
<button
type="button"
aria-labelledby={fileNameId}
aria-describedby={fileDescriptionId}
aria-current={isSelected ? "true" : undefined}
data-file-button-index={index}
disabled={disabled}
tabIndex={tabStop ? 0 : -1}
onClick={() => {
selectFile(file.id);
onActivate?.(file.id);
}}
onKeyDown={(event) => {
if (
!disabled &&
(event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Home" ||
event.key === "End")
) {
event.preventDefault();
onNavigate(index, event.key);
}
}}
className="flex flex-1 min-w-0 items-center gap-3 ps-3 pe-3 py-2 text-start rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40 disabled:cursor-not-allowed"
>
V{file.version}
</span>
{/* File name */}
<span
id={fileNameId}
className="flex-1 min-w-0 text-sm font-medium text-foreground truncate"
>
{file.originalName}
</span>
<span id={fileDescriptionId} className="sr-only">
{t.files.version} {file.version}, {t.files.size}: {formatSize(file.size)},{" "}
{formatDate(file.createdAt)}
{file.toolChain.length > 0
? `, ${t.files.toolsUsed}: ${file.toolChain.map(toolName).join(", ")}`
: ""}
</span>
{/* Size */}
<span className="hidden sm:block text-xs text-muted-foreground shrink-0 w-16 text-end">
{formatSize(file.size)}
</span>
{/* Tool chain */}
{file.toolChain.length > 0 && (
<span className="hidden md:block text-xs text-primary-ink shrink-0">
{file.toolChain.map(toolName).join(" → ")}
</span>
)}
{/* Date */}
<span className="hidden lg:block text-xs text-muted-foreground shrink-0 w-24 text-end">
{formatDate(file.createdAt)}
</span>
</div>
{/* Version badge */}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium shrink-0",
file.version >= 2
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
V{file.version}
</span>
{/* Size */}
<span className="hidden sm:block text-xs text-muted-foreground shrink-0 w-16 text-end">
{formatSize(file.size)}
</span>
{/* Date */}
<span className="hidden lg:block text-xs text-muted-foreground shrink-0 w-24 text-end">
{formatDate(file.createdAt)}
</span>
</button>
</li>
);
}
+73 -54
View File
@@ -4,9 +4,14 @@ import { useTranslation } from "@/contexts/i18n-context";
import { getFileDownloadUrl } from "@/lib/api";
import { format } from "@/lib/format";
import { useFilesPageStore } from "@/stores/files-page-store";
import { FileListItem } from "./file-list-item";
import { FileListItem, type FileNavigationKey } from "./file-list-item";
export function FileList({ filterMimePrefix }: { filterMimePrefix?: string }) {
interface FileListProps {
filterMimePrefix?: string;
onFileActivate?: (fileId: string) => void;
}
export function FileList({ filterMimePrefix, onFileActivate }: FileListProps) {
const { t } = useTranslation();
const {
files: allFiles,
@@ -23,7 +28,7 @@ export function FileList({ filterMimePrefix }: { filterMimePrefix?: string }) {
const [inputValue, setInputValue] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const files = allFiles;
@@ -52,38 +57,50 @@ export function FileList({ filterMimePrefix }: { filterMimePrefix?: string }) {
}
}
const handleListKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (files.length === 0) return;
const currentIndex = selectedFileId ? files.findIndex((f) => f.id === selectedFileId) : -1;
const enabledFileIndices = files.flatMap((file, index) =>
filterMimePrefix && !file.mimeType.startsWith(filterMimePrefix) ? [] : [index],
);
const selectedFileIndex = selectedFileId
? files.findIndex((file) => file.id === selectedFileId)
: -1;
const tabStopIndex = enabledFileIndices.includes(selectedFileIndex)
? selectedFileIndex
: (enabledFileIndices[0] ?? -1);
if (e.key === "ArrowDown") {
e.preventDefault();
const next = currentIndex < files.length - 1 ? currentIndex + 1 : 0;
selectFile(files[next].id);
listRef.current
?.querySelector(`[data-file-id="${files[next].id}"]`)
?.scrollIntoView({ block: "nearest" });
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = currentIndex > 0 ? currentIndex - 1 : files.length - 1;
selectFile(files[prev].id);
listRef.current
?.querySelector(`[data-file-id="${files[prev].id}"]`)
?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Home") {
e.preventDefault();
selectFile(files[0].id);
} else if (e.key === "End") {
e.preventDefault();
selectFile(files[files.length - 1].id);
const handleFileNavigate = useCallback(
(currentIndex: number, key: FileNavigationKey) => {
const enabledIndices = files.flatMap((file, index) =>
filterMimePrefix && !file.mimeType.startsWith(filterMimePrefix) ? [] : [index],
);
if (enabledIndices.length === 0) return;
const currentEnabledIndex = enabledIndices.indexOf(currentIndex);
let targetIndex: number;
if (key === "Home") targetIndex = enabledIndices[0];
else if (key === "End") targetIndex = enabledIndices[enabledIndices.length - 1];
else if (key === "ArrowDown") {
targetIndex = enabledIndices[(currentEnabledIndex + 1) % enabledIndices.length];
} else {
const previousIndex =
currentEnabledIndex <= 0 ? enabledIndices.length - 1 : currentEnabledIndex - 1;
targetIndex = enabledIndices[previousIndex];
}
selectFile(files[targetIndex].id);
const target = listRef.current?.querySelector<HTMLButtonElement>(
`[data-file-button-index="${targetIndex}"]`,
);
target?.focus();
target?.scrollIntoView({ block: "nearest" });
},
[files, selectedFileId, selectFile],
[files, filterMimePrefix, selectFile],
);
const allChecked = files.length > 0 && checkedIds.size === files.length;
const someChecked = checkedIds.size > 0;
const hasFileItems = !loading && !error && files.length > 0;
const listClassName =
"flex-1 overflow-y-auto p-2 space-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring";
return (
<div className="flex-1 flex flex-col overflow-hidden border-r border-border">
@@ -105,6 +122,7 @@ export function FileList({ filterMimePrefix }: { filterMimePrefix?: string }) {
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<input
type="checkbox"
aria-label={`${t.files.selectFile}: ${t.files.myFiles}`}
checked={allChecked}
onChange={toggleCheckAll}
className="h-4 w-4 accent-primary"
@@ -137,38 +155,39 @@ export function FileList({ filterMimePrefix }: { filterMimePrefix?: string }) {
</div>
{/* File list */}
<div
ref={listRef}
role="listbox"
tabIndex={0}
onKeyDown={handleListKeyDown}
className="flex-1 overflow-y-auto p-2 space-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{loading && (
<div className="flex items-center justify-center h-32">
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && error && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!loading && !error && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">{t.files.noFilesFound}</p>
</div>
)}
{!loading &&
!error &&
files.map((file) => (
{hasFileItems ? (
<ul ref={listRef} className={`${listClassName} m-0 list-none`}>
{files.map((file, index) => (
<FileListItem
key={file.id}
file={file}
index={index}
disabled={!!filterMimePrefix && !file.mimeType.startsWith(filterMimePrefix)}
tabStop={index === tabStopIndex}
onActivate={onFileActivate}
onNavigate={handleFileNavigate}
/>
))}
</div>
</ul>
) : (
<div className={listClassName}>
{loading && (
<div className="flex items-center justify-center h-32">
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && error && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!loading && !error && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">{t.files.noFilesFound}</p>
</div>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function FilesNav() {
return (
<div className="w-48 border-e border-border p-4 shrink-0 hidden md:block">
<h3 className="text-sm font-semibold text-foreground mb-3">{t.files.myFiles}</h3>
<h2 className="text-sm font-semibold text-foreground mb-3">{t.files.myFiles}</h2>
<div className="space-y-1">
{items.map((item) => (
<button
@@ -82,6 +82,7 @@ export function AvatarDropdown({ onSettingsClick, variant = "light" }: AvatarDro
{/* Settings */}
<button
type="button"
data-testid="open-settings"
onClick={() => {
setOpen(false);
onSettingsClick();
@@ -1,5 +1,5 @@
import { FolderOpen, LayoutGrid, Settings as SettingsIcon, Workflow } from "lucide-react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { ImageEditIcon } from "../common/image-edit-icon";
@@ -23,6 +23,7 @@ export function MobileBottomNav({ onSettingsClick }: MobileBottomNavProps) {
<button
type="button"
onClick={onSettingsClick}
data-testid="open-settings"
className="flex flex-col items-center gap-0.5 px-3 py-2 text-muted-foreground"
>
<SettingsIcon className="h-6 w-6" />
+1 -1
View File
@@ -13,7 +13,7 @@ import {
Workflow,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { useTheme } from "@/hooks/use-theme";
@@ -5,7 +5,7 @@ import {
} from "@snapotter/shared";
import { Building2, GraduationCap, Search, User, Users, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPut } from "@/lib/api";
@@ -1,7 +1,7 @@
import { BASE_CONFIG, CONVERSION_PRESET_BY_ID } from "@snapotter/shared";
import { Download } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useParams } from "react-router";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
@@ -1,7 +1,7 @@
import type { LibrarySaveMode } from "@snapotter/shared";
import { Check, CheckCircle2, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
@@ -202,7 +202,7 @@ export function PipelineBuilder({
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Workflow className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">{t.automate.noStepsHeading}</h3>
<h2 className="text-sm font-medium text-foreground mb-1">{t.automate.noStepsHeading}</h2>
<p className="text-sm text-muted-foreground max-w-[240px]">{t.automate.addToolsPrompt}</p>
</div>
);
+13 -5
View File
@@ -20,6 +20,13 @@ function isInputFocused(): boolean {
return false;
}
function isNativeInteractiveTarget(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest('a[href], button, input, select, textarea, [contenteditable="true"]') !== null
);
}
// Brush size step depends on current size for natural feel
function getBrushSizeStep(current: number): number {
if (current < 10) return 1;
@@ -605,11 +612,11 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"tab",
(e) => {
if (isInputFocused()) return;
if (isInputFocused() || isNativeInteractiveTarget(e.target)) return;
e.preventDefault();
useEditorStore.getState().toggleRightPanel();
},
{ preventDefault: true },
{ preventDefault: false },
);
// Arrow keys - Nudge selected 1px
@@ -698,14 +705,14 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"enter",
(e) => {
if (isInputFocused()) return;
if (isInputFocused() || isNativeInteractiveTarget(e.target)) return;
e.preventDefault();
const state = useEditorStore.getState();
if (state.isCropping && state.cropState) {
state.applyCrop();
}
},
{ preventDefault: true },
{ preventDefault: false },
);
// Escape - Cancel current operation
@@ -855,7 +862,7 @@ export function useEditorShortcuts(callbacks?: {
// ---- Space key: temporary hand tool ----
const handleSpaceDown = useCallback((e: KeyboardEvent) => {
if (isInputFocused()) return;
if (isInputFocused() || isNativeInteractiveTarget(e.target)) return;
if (e.code !== "Space") return;
if (e.repeat) return;
e.preventDefault();
@@ -871,6 +878,7 @@ export function useEditorShortcuts(callbacks?: {
const handleSpaceUp = useCallback((e: KeyboardEvent) => {
if (e.code !== "Space") return;
if (!isSpaceHeldRef.current && isNativeInteractiveTarget(e.target)) return;
e.preventDefault();
if (isSpaceHeldRef.current) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { TOOLS } from "@snapotter/shared";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import { useTheme } from "./use-theme";
/**
+2 -1
View File
@@ -22,7 +22,7 @@ import {
X,
} from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router";
import { TemplatesSection } from "@/components/automate/templates-section";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { Dropzone } from "@/components/common/dropzone";
@@ -710,6 +710,7 @@ export function AutomatePage() {
<button
type="button"
onClick={() => setMobileToolPaletteOpen(true)}
aria-label={t.automate.addTool}
className="fixed bottom-24 right-4 z-20 w-14 h-14 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center hover:bg-primary/90 active:scale-95 transition-transform"
>
<Plus className="h-6 w-6" />
+2 -2
View File
@@ -118,7 +118,7 @@ export function ChangePasswordPage() {
};
return (
<div className="flex h-dvh bg-background">
<main id="main-content" tabIndex={-1} className="flex h-dvh bg-background">
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
<div>
@@ -230,6 +230,6 @@ export function ChangePasswordPage() {
<p className="text-lg text-primary-foreground">{t.changePassword.sidebarDescription}</p>
</div>
</div>
</div>
</main>
);
}
+11 -2
View File
@@ -148,7 +148,12 @@ export function EditorPage() {
if (isMobile) {
return (
<main className="flex flex-col items-center justify-center h-full p-8 text-center">
<main
id="main-content"
tabIndex={-1}
className="flex flex-col items-center justify-center h-full p-8 text-center"
>
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
<Monitor size={48} className="text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">{t.editor.mobile.heading}</h2>
<p className="text-sm text-muted-foreground max-w-sm">{t.editor.mobile.description}</p>
@@ -157,7 +162,11 @@ export function EditorPage() {
}
return (
<main className="flex flex-col h-dvh overflow-hidden bg-background text-foreground">
<main
id="main-content"
tabIndex={-1}
className="flex flex-col h-dvh overflow-hidden bg-background text-foreground"
>
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
+8 -15
View File
@@ -1,6 +1,6 @@
import { X } from "lucide-react";
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import { FileDetails } from "@/components/files/file-details";
import { FileList } from "@/components/files/file-list";
import { FileUploadArea } from "@/components/files/file-upload-area";
@@ -59,18 +59,11 @@ export function FilesPage() {
</div>
{activeTab === "recent" ? (
<div
role="listbox"
tabIndex={0}
className="flex-1 overflow-hidden"
onClick={() => {
if (selectedFileId) setShowDetails(true);
}}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && selectedFileId) setShowDetails(true);
}}
>
<FileList filterMimePrefix={filterMimePrefix} />
<div className="flex-1 overflow-hidden">
<FileList
filterMimePrefix={filterMimePrefix}
onFileActivate={() => setShowDetails(true)}
/>
</div>
) : (
<FileUploadArea />
@@ -97,7 +90,7 @@ export function FilesPage() {
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
<FileDetails mobile />
<FileDetails filterMimePrefix={filterMimePrefix} mobile />
</div>
</div>
)}
@@ -114,7 +107,7 @@ export function FilesPage() {
{activeTab === "recent" ? (
<>
<FileList filterMimePrefix={filterMimePrefix} />
<FileDetails />
<FileDetails filterMimePrefix={filterMimePrefix} />
</>
) : (
<FileUploadArea />
+28 -26
View File
@@ -2,7 +2,7 @@ import type { Tool } from "@snapotter/shared";
import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
import { ChevronDown, Plus, Search, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router";
import { ToolCard } from "@/components/common/tool-card.js";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog.js";
import { AppLayout } from "@/components/layout/app-layout.js";
@@ -541,32 +541,34 @@ function AllTabContent({
return (
<section key={sec.id}>
<button
type="button"
onClick={() => toggleSection(sec.id)}
className="w-full flex items-center gap-2 py-2 mb-2 border-b border-border/40 group cursor-pointer"
>
{SectionIcon && (
<div
className="p-1 rounded"
style={{
backgroundColor: `${sec.color}15`,
color: sec.color,
}}
>
<SectionIcon className="h-4 w-4" />
</div>
)}
<span className="text-sm font-semibold text-foreground">{sec.name}</span>
<span className="text-xs text-muted-foreground">{totalCount}</span>
<div className="flex-1" />
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
isCollapsed && "-rotate-90",
<h2>
<button
type="button"
onClick={() => toggleSection(sec.id)}
className="w-full flex items-center gap-2 py-2 mb-2 border-b border-border/40 group cursor-pointer"
>
{SectionIcon && (
<span
className="p-1 rounded"
style={{
backgroundColor: `${sec.color}15`,
color: sec.color,
}}
>
<SectionIcon className="h-4 w-4" />
</span>
)}
/>
</button>
<span className="text-sm font-semibold text-foreground">{sec.name}</span>
<span className="text-xs text-muted-foreground">{totalCount}</span>
<span className="flex-1" />
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
isCollapsed && "-rotate-90",
)}
/>
</button>
</h2>
{!isCollapsed && (
<div className="space-y-5 ps-1">
+2 -2
View File
@@ -1,6 +1,6 @@
import { KeyRound } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { setToken } from "@/lib/api";
@@ -246,7 +246,7 @@ export function LoginPage() {
};
return (
<main className="flex h-dvh bg-background">
<main id="main-content" tabIndex={-1} className="flex h-dvh bg-background">
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
<div>
+7 -3
View File
@@ -1,11 +1,15 @@
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div className="flex h-dvh items-center justify-center bg-background text-foreground">
<main
id="main-content"
tabIndex={-1}
className="flex h-dvh items-center justify-center bg-background text-foreground"
>
<div className="text-center space-y-4 max-w-md px-6">
<h1 className="text-6xl font-bold text-primary-ink">404</h1>
<h2 className="text-xl font-semibold">{t.common.pageNotFound}</h2>
@@ -17,6 +21,6 @@ export function NotFoundPage() {
{t.common.goHome}
</Link>
</div>
</div>
</main>
);
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { ArrowLeft } from "lucide-react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useTranslation } from "@/contexts/i18n-context";
export function PrivacyPolicyPage() {
const { t } = useTranslation();
return (
<div className="min-h-screen bg-background text-foreground">
<main id="main-content" tabIndex={-1} className="min-h-screen bg-background text-foreground">
<div className="max-w-2xl mx-auto px-6 py-12">
<Link
to="/"
@@ -96,6 +96,6 @@ export function PrivacyPolicyPage() {
</section>
</div>
</div>
</div>
</main>
);
}
+14 -8
View File
@@ -22,7 +22,7 @@ import {
} from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
import { Link, useLocation, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone";
@@ -219,13 +219,13 @@ function FileSelectionInfo({
export function ToolPage() {
const { t } = useTranslation();
const { toolId } = useParams<{ toolId: string }>();
const { section, toolId } = useParams<{ section: string; toolId: string }>();
const location = useLocation();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const registryEntry = useMemo(
() => (toolId ? getToolRegistryEntry(toolId) : undefined),
[toolId],
const tool = useMemo(
() => TOOLS.find((t) => t.id === toolId && t.route === `/${section}/${toolId}`),
[section, toolId],
);
const registryEntry = useMemo(() => (tool ? getToolRegistryEntry(tool.id) : undefined), [tool]);
const isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false;
const featuresLoaded = useFeaturesStore((s) => s.loaded);
const featureBundles = useFeaturesStore((s) => s.bundles);
@@ -555,7 +555,7 @@ export function ToolPage() {
URL.revokeObjectURL(url);
}, [batchZipBlob, batchZipFilename]);
if (toolId && TOOLS.some((tt) => tt.id === toolId) && disabledTools.includes(toolId)) {
if (tool && disabledTools.includes(tool.id)) {
return (
<AppLayout>
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
@@ -1373,7 +1373,13 @@ export function ToolPage() {
{liveMessage}
</div>
<div
key={hasProcessed ? `processed-${selectedIndex}` : `pending-${selectedIndex}`}
key={
hasProcessed
? `processed-${selectedIndex}`
: displayMode === "interactive-eraser"
? "pending-eraser"
: `pending-${selectedIndex}`
}
className={`flex-1 relative flex items-center justify-center p-6 min-h-0 min-w-0 ${hasProcessed ? "animate-fade-in" : ""}${isProcessing ? " animate-pulse" : ""}`}
>
{renderNavArrows()}