fix: resolve remaining QA issues -- editor features, masking, persistence, Playwright tests

Phase 1 quick fixes:
- Add isInputFocused() guard to Cmd+A/D/T/J shortcuts (P1-7)
- Add Go Home button to tool-not-found page (P2-30)
- Fix hardcoded "Import from Library" string in file library modal (P2-28)
- Fix TeamEntry.id type from number to string to match API (P2-6)
- Add eye toggle to confirm password field (P2-10)
- Add Apply/Cancel buttons to Free Transform options bar (P2-14)

Phase 2 state fixes:
- Add sessionStorage persistence to pipeline store (P1-26)
- Fix Free Transform 0 dimensions by falling back to selection bounds (P1-6)

Phase 3 editor features:
- Constrain brush/eraser drawing within active selection bounds (P1-5)
- Add feather radius control to selection options (P2-21)
- Add flow control slider to brush options (P2-22)
- Add brush/block mode selector to eraser options (P2-23)
- Add estimated file size display to export dialog (P2-18)

Phase 4:
- Add Playwright e2e tests for key fixes (404 page, routing, pipeline persistence, export dialog)
This commit is contained in:
SnapOtter
2026-06-05 23:14:57 +08:00
parent 07e12754ba
commit 190d5c84bf
40 changed files with 1144 additions and 500 deletions
+5 -1
View File
@@ -4,7 +4,9 @@ import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { OtterLogo } from "@/components/common/otter-logo";
import { MobileBottomNav } from "@/components/layout/mobile-bottom-nav";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { track } from "@/lib/analytics";
import { apiGet } from "@/lib/api";
import { ICON_MAP } from "@/lib/icon-map";
@@ -14,6 +16,7 @@ import { useFeaturesStore } from "@/stores/features-store";
export function FullscreenGridPage() {
const { t } = useTranslation();
const isMobile = useMobile();
const [search, setSearch] = useState("");
const [showDetails, setShowDetails] = useState(true);
const navigate = useNavigate();
@@ -79,7 +82,7 @@ export function FullscreenGridPage() {
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
return (
<div className="min-h-screen bg-background text-foreground">
<div className={cn("min-h-screen bg-background text-foreground", isMobile && "pb-20")}>
{/* Top bar */}
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-4">
@@ -154,6 +157,7 @@ export function FullscreenGridPage() {
</div>
)}
</main>
{isMobile && <MobileBottomNav />}
</div>
);
}
+88 -2
View File
@@ -6,6 +6,7 @@ import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getToolName } from "@/lib/tool-i18n";
import { useFeaturesStore } from "@/stores/features-store";
@@ -32,6 +33,7 @@ export function HomePage() {
const location = useLocation();
const { fetch: fetchSettings, defaultToolView, loaded: settingsLoaded } = useSettingsStore();
const { fetch: fetchFeatures, bundles, installing, queued } = useFeaturesStore();
const isMobile = useMobile();
useEffect(() => {
if (location.state?.fromLibrary) {
@@ -93,12 +95,96 @@ export function HomePage() {
return <AppLayout onFiles={handleFiles} onUrlImport={handleUrlImport} />;
}
// File uploaded — show tool selector on left, image preview on right
// File uploaded — mobile: stacked layout
if (isMobile && hasFile) {
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<div className="flex flex-col h-full w-full">
{/* File info bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<ICON_MAP.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<span className="truncate text-sm font-medium text-foreground">
{selectedFileName ?? files[0].name}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
</span>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground ms-auto shrink-0"
>
{t.homePage.changeFile}
</button>
</div>
{/* Quick action buttons - horizontal scroll */}
<div className="flex overflow-x-auto gap-2 px-4 py-3 border-b border-border scrollbar-none">
{QUICK_ACTION_IDS.map((id) => {
const tool = TOOLS.find((t) => t.id === id);
if (!tool) return null;
const Icon =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ??
ICON_MAP.FileImage;
const status = getToolStatus(id);
return (
<button
key={id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors shrink-0"
>
<div className="p-1 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-foreground whitespace-nowrap">
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground" />
)}
{status === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
)}
</button>
);
})}
</div>
{/* Full-width image preview */}
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
{files.length > 1 ? (
<MultiImageViewer />
) : currentEntry?.previewLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
/>
) : (
<div className="text-center text-muted-foreground">
<p>{t.homePage.loadingPreview}</p>
</div>
)}
</div>
</div>
</AppLayout>
);
}
// File uploaded — desktop: tool selector on left, image preview on right
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<div className="flex h-full w-full">
{/* Left panel: Tool selector */}
<div className="w-80 border-r border-border overflow-y-auto shrink-0">
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0">
{/* File info */}
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2 text-sm">
+25 -16
View File
@@ -9,8 +9,9 @@ import {
} from "lucide-react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { ReviewPanel } from "@/components/common/review-panel";
@@ -213,7 +214,7 @@ export function ToolPage() {
},
[navigateNext, navigatePrev],
);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>("");
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
@@ -274,7 +275,7 @@ export function ToolPage() {
setEraserMaskedCount(0);
setEraserBrushSize(30);
setEraserSliderInitPos(null);
setMobileSettingsOpen(true);
setMobileSettingsOpen(false);
}, [toolId]);
const toolAccept = registryEntry?.accept;
@@ -346,8 +347,14 @@ export function ToolPage() {
if (!tool || !registryEntry) {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
{t.toolPage.notFound}
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<p className="text-lg font-medium">{t.toolPage.notFound}</p>
<Link
to="/"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
{t.common.goHome}
</Link>
</div>
</AppLayout>
);
@@ -777,7 +784,7 @@ export function ToolPage() {
);
}
// Mobile layout: settings above dropzone (stacked)
// Mobile layout: full-height image area with BottomSheet for settings
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
@@ -795,18 +802,11 @@ export function ToolPage() {
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
{mobileSettingsOpen ? t.toolPage.hideSettings : t.common.settings}
{t.common.settings}
</button>
</div>
{/* Collapsible settings */}
{mobileSettingsOpen && (
<div className="p-4 border-b border-border space-y-3 shrink-0 max-h-[40vh] overflow-y-auto">
{renderSettingsContent()}
</div>
)}
{/* Main area: image viewer */}
{/* Main area: image viewer (full height) */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0 min-w-0"
@@ -825,6 +825,15 @@ export function ToolPage() {
/>
)}
</section>
{/* Settings BottomSheet */}
<BottomSheet
open={mobileSettingsOpen}
onClose={() => setMobileSettingsOpen(false)}
title={t.common.settings}
>
<div className="settings-container space-y-3">{renderSettingsContent()}</div>
</BottomSheet>
</div>
</AppLayout>
);
@@ -835,7 +844,7 @@ export function ToolPage() {
<AppLayout showToolPanel={false}>
<div className="flex h-full w-full">
{/* Tool Settings Panel */}
<div className="w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
<div className="settings-container w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />