mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add full-width dropzone state and tool branding to tool page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user