Files
SnapOtter/apps/web/src/pages/fullscreen-grid-page.tsx
T
SnapOtter 190d5c84bf 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)
2026-06-05 23:14:57 +08:00

241 lines
8.7 KiB
TypeScript

import type { CategoryInfo, Tool } from "@snapotter/shared";
import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@snapotter/shared";
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";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
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();
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const fetchFeatures = useFeaturesStore((s) => s.fetch);
useEffect(() => {
fetchFeatures();
}, [fetchFeatures]);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => {
setDisabledTools(
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
);
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
})
.catch(() => {});
}, []);
const visibleTools = useMemo(() => {
return TOOLS.filter((t) => {
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
return true;
});
}, [disabledTools, experimentalEnabled]);
const filteredTools = useMemo(() => {
if (!search) return visibleTools;
const q = search.toLowerCase();
return visibleTools.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q),
);
}, [search, visibleTools]);
useEffect(() => {
if (!search) return;
const timer = setTimeout(() => {
track(ANALYTICS_EVENTS.SEARCH, {
query: search,
results_count: filteredTools.length,
});
}, 1000);
return () => clearTimeout(timer);
}, [search, filteredTools.length]);
const groupedTools = useMemo(() => {
const groups = new Map<string, Tool[]>();
for (const tool of filteredTools) {
const list = groups.get(tool.category) || [];
list.push(tool);
groups.set(tool.category, list);
}
return groups;
}, [filteredTools]);
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
return (
<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">
<Link
to="/"
className="flex items-center gap-2 text-lg font-bold text-foreground shrink-0"
>
<OtterLogo className="h-6 w-6 text-primary" />
<span className="text-primary">SnapOtter</span>
</Link>
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t.common.search}
className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{/* Toggle details */}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
showDetails
? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground hover:bg-muted",
)}
>
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="hidden sm:inline">
{showDetails ? t.fullscreenGrid.hideDetails : t.fullscreenGrid.showDetails}
</span>
</button>
{/* Switch to sidebar view */}
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
title="Switch to sidebar view"
>
<List className="h-4 w-4" />
<span className="hidden sm:inline">{t.fullscreenGrid.sidebarButton}</span>
</button>
</div>
</header>
{/* Grid */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
{activeCategories.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{activeCategories.map((category) => (
<CategoryCard
key={category.id}
category={category}
tools={groupedTools.get(category.id) || []}
showDetails={showDetails}
/>
))}
</div>
)}
</main>
{isMobile && <MobileBottomNav />}
</div>
);
}
function CategoryCard({
category,
tools,
showDetails,
}: {
category: CategoryInfo;
tools: Tool[];
showDetails: boolean;
}) {
const { t } = useTranslation();
const CategoryIcon =
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow">
{/* Header */}
<div
className="px-4 py-3 flex items-center gap-3"
style={{ backgroundColor: `${category.color}15` }}
>
<div
className="p-2 rounded-lg"
style={{ backgroundColor: `${category.color}25`, color: category.color }}
>
<CategoryIcon className="h-5 w-5" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground text-sm">
{getCategoryName(t, category.id, category.name)}
</h3>
</div>
<span
className="text-xs font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
{tools.length}
</span>
</div>
{/* Tool list */}
<div className="p-2">
{tools.map((tool) => {
const ToolIcon =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<Link
key={tool.id}
to={tool.route}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<ToolIcon className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</p>
{showDetails && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{getToolDescription(t, tool.id, tool.description)}
</p>
)}
</div>
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
{t.common.experimental}
</span>
)}
</Link>
);
})}
</div>
</div>
);
}