feat: add full-width dropzone state and tool branding to tool page

This commit is contained in:
SnapOtter
2026-06-14 18:55:06 +08:00
parent 0d751b7d14
commit f2ac05747e
23 changed files with 234 additions and 8 deletions
@@ -0,0 +1,85 @@
import type { Tool } from "@snapotter/shared";
import { FileImage, Lock } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { getToolDescription, getToolName } from "@/lib/tool-i18n";
import { Dropzone } from "./dropzone";
interface ToolDropzoneProps {
tool: Tool;
accept?: string;
fileFilter?: (file: File) => boolean;
multiple?: boolean;
onFiles: (files: File[]) => void;
onUrlImport?: (file: File) => void;
}
const FORMATS_LIMIT = 8;
export function ToolDropzone({
tool,
accept,
fileFilter,
multiple,
onFiles,
onUrlImport,
}: ToolDropzoneProps) {
const { t } = useTranslation();
const IconComponent =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
const isMultiFile = MULTI_FILE_TOOLS.has(tool.id);
const resolvedMultiple = multiple ?? isMultiFile;
const formatsDisplay = useMemo(() => {
const inputs = tool.acceptedInputs;
if (!inputs || inputs.length === 0) return null;
const formatted = inputs.map((ext) => ext.replace(/^\./, "").toUpperCase());
if (formatted.length <= FORMATS_LIMIT) {
return formatted.join(", ");
}
const visible = formatted.slice(0, FORMATS_LIMIT).join(", ");
return `${visible} ${format(t.toolPage.andMore, { count: formatted.length - FORMATS_LIMIT })}`;
}, [tool.acceptedInputs, t.toolPage.andMore]);
return (
<div className="flex flex-col items-center gap-6 w-full max-w-lg px-4 py-8">
{/* Tool branding */}
<div className="flex flex-col items-center gap-3 text-center">
<div className="p-3 rounded-xl bg-primary/10">
<IconComponent className="h-8 w-8 text-primary" />
</div>
<h1 className="text-xl font-semibold text-foreground">
{getToolName(t, tool.id, tool.name)}
</h1>
<p className="text-sm text-muted-foreground max-w-sm">
{getToolDescription(t, tool.id, tool.description)}
</p>
</div>
{/* Dropzone */}
<Dropzone
onFiles={onFiles}
onUrlImport={onUrlImport}
accept={accept}
multiple={resolvedMultiple}
fileFilter={fileFilter}
/>
{/* Accepted formats */}
{formatsDisplay && (
<p className="text-xs text-muted-foreground text-center">{formatsDisplay}</p>
)}
{/* Privacy note */}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="h-3 w-3 shrink-0" />
{t.toolPage.privacyNote}
</p>
</div>
);
}
+65 -8
View File
@@ -17,6 +17,7 @@ import { type BgPreviewState, ImageViewer } from "@/components/common/image-view
import { ReviewPanel } from "@/components/common/review-panel";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { ToolDropzone } from "@/components/common/tool-dropzone";
import { FeatureInstallPrompt } from "@/components/features/feature-install-prompt";
import { AppLayout } from "@/components/layout/app-layout";
import { CropCanvas } from "@/components/tools/crop-canvas";
@@ -26,8 +27,8 @@ import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
import { recordRecentTool } from "@/hooks/use-recent-tools";
import { usePageTitle } from "@/hooks/use-page-title";
import { recordRecentTool } from "@/hooks/use-recent-tools";
import { formatFileSize } from "@/lib/download";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
@@ -41,6 +42,7 @@ import { useFileStore } from "@/stores/file-store";
import { useHtmlToImageStore } from "@/stores/html-to-image-store";
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
import { useQrStore } from "@/stores/qr-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useSplitStore } from "@/stores/split-store";
const MediaPlayerView = lazy(() =>
@@ -183,6 +185,7 @@ export function ToolPage() {
usePageTitle(tool ? getToolName(t, tool.id, tool.name) : undefined);
const { hasPermission } = useAuth();
const isAdmin = hasPermission("settings:write");
const disabledTools = useSettingsStore((s) => s.disabledTools);
const breadcrumb = useMemo(() => {
if (!tool) return undefined;
@@ -382,6 +385,22 @@ export function ToolPage() {
URL.revokeObjectURL(url);
}, [batchZipBlob, batchZipFilename]);
if (toolId && TOOLS.some((tt) => tt.id === toolId) && disabledTools.includes(toolId)) {
return (
<AppLayout>
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<p className="text-lg font-medium">{t.toolPage.disabledByAdmin}</p>
<Link
to="/"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
{t.toolPage.browseOtherTools}
</Link>
</div>
</AppLayout>
);
}
if (!tool || !registryEntry) {
return (
<AppLayout>
@@ -410,13 +429,15 @@ export function ToolPage() {
if (isAiTool && !toolInstalled && featureBundle) {
return (
<AppLayout>
<FeatureInstallPrompt
bundle={featureBundle}
isAdmin={isAdmin}
toolName={tool?.name}
toolDescription={tool?.description}
/>
<AppLayout breadcrumb={breadcrumb}>
<div className="flex-1 flex items-center justify-center overflow-y-auto bg-muted/20">
<FeatureInstallPrompt
bundle={featureBundle}
isAdmin={isAdmin}
toolName={tool?.name}
toolDescription={tool?.description}
/>
</div>
</AppLayout>
);
}
@@ -849,6 +870,24 @@ export function ToolPage() {
// Mobile layout: full-height image area with BottomSheet for settings
if (isMobile) {
// Full-width dropzone when no file is loaded (non-generator tools)
if (!hasFile && !isNoDropzone) {
return (
<AppLayout breadcrumb={breadcrumb}>
<div className="flex-1 flex items-center justify-center overflow-y-auto bg-muted/20">
<ToolDropzone
tool={tool}
accept={toolAccept}
fileFilter={toolFileFilter}
multiple
onFiles={handleFiles}
onUrlImport={handleUrlImport}
/>
</div>
</AppLayout>
);
}
return (
<AppLayout breadcrumb={breadcrumb}>
<div className="flex flex-col w-full h-full">
@@ -905,6 +944,24 @@ export function ToolPage() {
);
}
// Desktop: full-width dropzone when no file is loaded (non-generator tools)
if (!hasFile && !isNoDropzone) {
return (
<AppLayout breadcrumb={breadcrumb}>
<div className="flex-1 flex items-center justify-center overflow-y-auto bg-muted/20">
<ToolDropzone
tool={tool}
accept={toolAccept}
fileFilter={toolFileFilter}
multiple
onFiles={handleFiles}
onUrlImport={handleUrlImport}
/>
</div>
</AppLayout>
);
}
// Desktop layout: side-by-side
return (
<AppLayout breadcrumb={breadcrumb}>