mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user