Merge branch 'feat/web-otter-orange-design'

This commit is contained in:
SnapOtter
2026-06-14 15:11:56 +08:00
16 changed files with 110 additions and 222 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { FileUp, Upload } from "lucide-react";
import { FileImage, FileUp, Upload } from "lucide-react";
import { type DragEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useUrlImport } from "@/hooks/use-url-import";
@@ -258,7 +258,7 @@ export function CropOverlay() {
keepRatio={false}
anchorSize={8}
anchorStroke="#ffffff"
anchorFill="#3b82f6"
anchorFill="#E07832"
anchorCornerRadius={1}
borderStroke="#ffffff"
borderStrokeWidth={1}
@@ -469,10 +469,10 @@ export function MoveToolTransformer({
flipEnabled
keepRatio={false}
anchorSize={8}
anchorStroke="#3b82f6"
anchorStroke="#E07832"
anchorFill="#ffffff"
anchorCornerRadius={2}
borderStroke="#3b82f6"
borderStroke="#E07832"
borderStrokeWidth={1}
padding={2}
/>
@@ -793,7 +793,7 @@ export function ActiveSelectionPreview({
}) {
if (type === "lasso" && points.length >= 4) {
return (
<Line points={points} stroke="#3b82f6" strokeWidth={1} dash={[4, 4]} listening={false} />
<Line points={points} stroke="#E07832" strokeWidth={1} dash={[4, 4]} listening={false} />
);
}
@@ -812,7 +812,7 @@ export function ActiveSelectionPreview({
y={y + h / 2}
radiusX={w / 2}
radiusY={h / 2}
stroke="#3b82f6"
stroke="#E07832"
strokeWidth={1}
dash={[4, 4]}
listening={false}
@@ -826,7 +826,7 @@ export function ActiveSelectionPreview({
y={y}
width={w}
height={h}
stroke="#3b82f6"
stroke="#E07832"
strokeWidth={1}
dash={[4, 4]}
listening={false}
@@ -295,10 +295,10 @@ export function TransformToolTransformer({
300, 315, 330, 345,
]}
anchorSize={8}
anchorStroke="#3b82f6"
anchorStroke="#E07832"
anchorFill="#ffffff"
anchorCornerRadius={2}
borderStroke="#3b82f6"
borderStroke="#E07832"
borderStrokeWidth={1}
borderDash={[4, 4]}
padding={0}
@@ -188,8 +188,8 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
{/* Details card */}
<div className="flex-1">
<div className="rounded-lg border border-border overflow-hidden">
<div className="bg-blue-500/10 border-b border-border px-3 py-2">
<h4 className="text-sm font-semibold text-blue-600 dark:text-blue-400">
<div className="bg-primary/10 border-b border-border px-3 py-2">
<h4 className="text-sm font-semibold text-primary-dark dark:text-primary-light">
{t.files.fileDetailsHeading}
</h4>
</div>
@@ -119,10 +119,7 @@ export function ToolPanel() {
return (
<div key={modality.id} className={isLast ? "" : "mb-2"}>
{idx > 0 && <hr className="border-border mb-4" />}
<div
className="flex items-center gap-2 mt-4 mb-3 pl-2.5 border-l-[3px] rounded-sm"
style={{ borderColor: modality.color }}
>
<div className="flex items-center gap-2 mt-4 mb-3 pl-1">
{ModalityIcon && (
<span className="shrink-0" style={{ color: modality.color }}>
<ModalityIcon className="h-4.5 w-4.5" />
@@ -47,7 +47,7 @@ const FORMAT_LABELS: Record<string, string> = {
/** Badge colors by barcode family. */
function getBadgeColor(type: string): string {
if (type.includes("QR") || type === "Aztec" || type === "DataMatrix" || type === "MaxiCode")
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
return "bg-primary/15 text-primary-dark dark:text-primary-light";
if (type.includes("EAN") || type.includes("UPC") || type.includes("DataBar"))
return "bg-green-500/15 text-green-600 dark:text-green-400";
if (type === "PDF417") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
@@ -459,7 +459,7 @@ function CollageCell({
className={cn(
"relative overflow-hidden transition-shadow",
isSelected && "ring-2 ring-primary ring-offset-1",
isOver && !isSelected && "ring-2 ring-blue-500",
isOver && !isSelected && "ring-2 ring-primary",
!image && "border-2 border-dashed border-border/50",
image && !isSelected && "cursor-grab",
image && isSelected && "cursor-grab active:cursor-grabbing",
@@ -521,7 +521,7 @@ function CollageCell({
className={cn(
"flex items-center gap-1 rounded px-1.5 py-1 text-[11px] font-medium backdrop-blur-sm transition-colors",
transform.objectFit === "contain"
? "bg-blue-500/80 text-white hover:bg-blue-500/90"
? "bg-primary/80 text-white hover:bg-primary/90"
: "bg-black/50 text-white hover:bg-black/70",
)}
title={transform.objectFit === "cover" ? "Fit entire image" : "Fill cell"}
@@ -29,14 +29,17 @@ export function DocumentView() {
/* C+D: cancel in-flight renders and destroy the doc proxy on cleanup. */
useEffect(() => {
if (!src || !canvasRef.current) return;
if (!canvasRef.current) return;
const file = entry?.file;
if (!file && !src) return;
let cancelled = false;
let doc: pdfjs.PDFDocumentProxy | undefined;
let renderTask: pdfjs.RenderTask | undefined;
(async () => {
try {
doc = await pdfjs.getDocument({ url: src }).promise;
const source = file ? { data: new Uint8Array(await file.arrayBuffer()) } : { url: src! };
doc = await pdfjs.getDocument(source).promise;
if (cancelled) return;
setPageCount(doc.numPages);
const pdfPage = await doc.getPage(Math.min(page, doc.numPages));
@@ -60,7 +63,7 @@ export function DocumentView() {
renderTask?.cancel();
doc?.loadingTask.destroy();
};
}, [src, page]);
}, [src, page, entry?.file]);
if (!entry) return null;
@@ -1,7 +1,7 @@
import { Download, FileUp, Loader2, X } from "lucide-react";
import { useCallback, useRef } from "react";
import { Download, Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
const FORMAT_OPTIONS = [
@@ -41,57 +41,22 @@ const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"];
export function PdfToImageSettings() {
const { t } = useTranslation();
const store = usePdfToImageStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const file = useFileStore((s) => s.entries[s.selectedIndex]?.file);
useEffect(() => {
if (file && file !== store.file) {
store.setFile(file);
store.loadPreview(file);
}
}, [file, store]);
const isLossy = LOSSY_FORMATS.includes(store.format);
const handleFileChange = useCallback(
(files: FileList | null) => {
const pdfFile = files?.[0];
if (!pdfFile) return;
store.setFile(pdfFile);
store.loadPreview(pdfFile);
},
[store],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
handleFileChange(e.dataTransfer.files);
},
[handleFileChange],
);
const handleRemoveFile = useCallback(() => {
store.setFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [store]);
const selectedCount = store.selectedPages.size;
return (
<div className="space-y-4">
{/* PDF upload area */}
{!store.file ? (
<button
type="button"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors w-full"
>
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t.toolSettings["pdf-to-image"].dropPdf}</p>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={(e) => handleFileChange(e.target.files)}
/>
</button>
) : (
{/* PDF info */}
{store.file && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{store.file.name}</p>
@@ -106,13 +71,6 @@ export function PdfToImageSettings() {
) : null}
</p>
</div>
<button
type="button"
onClick={handleRemoveFile}
className="p-1 hover:bg-background rounded"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)}
+1 -1
View File
@@ -78,7 +78,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
favicon: "before-after",
"image-to-pdf": "before-after",
"optimize-for-web": "before-after",
"pdf-to-image": "no-dropzone",
"pdf-to-image": "custom-results",
// Adjustments extra
"replace-color": "before-after",
+3 -12
View File
@@ -219,10 +219,7 @@ export function FullscreenGridPage() {
}>;
return (
<section key={section.modality.id}>
<div
className="flex items-center gap-3 mb-5 pb-3 border-b-2"
style={{ borderColor: section.modality.color }}
>
<div className="flex items-center gap-3 mb-5 pb-3 border-b border-border">
{SectionIcon && (
<div
className="p-2 rounded-lg"
@@ -268,10 +265,7 @@ export function FullscreenGridPage() {
? (ICON_MAP[activeMod.icon] as React.ComponentType<{ className?: string }>)
: null;
return activeMod ? (
<div
className="flex items-center gap-3 mb-6 pb-3 border-b-2"
style={{ borderColor: activeMod.color }}
>
<div className="flex items-center gap-3 mb-6 pb-3 border-b border-border">
{ModIcon && (
<div
className="p-2 rounded-lg"
@@ -331,10 +325,7 @@ function CategoryCard({
(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"
style={accentColor ? { borderLeftWidth: 3, borderLeftColor: accentColor } : undefined}
>
<div className="rounded-xl border border-border bg-card overflow-hidden" style={undefined}>
{/* Header */}
<div
className="px-4 py-3 flex items-center gap-3"
+47 -112
View File
@@ -5,8 +5,8 @@ import {
TOOL_BUNDLE_MAP,
TOOLS,
} from "@snapotter/shared";
import { Clock, Download, FileArchive, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { Clock, Download, FileArchive, LayoutGrid, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
@@ -17,12 +17,12 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getModalityName, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
import { useFeaturesStore } from "@/stores/features-store";
import { useFileStore } from "@/stores/file-store";
import { useSettingsStore } from "@/stores/settings-store";
// Tools shown prominently as "quick actions" at the top
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
let hasAppliedDefaultRedirect = false;
@@ -96,6 +96,9 @@ export function HomePage() {
[setFiles],
);
const [modalityFilter, setModalityFilter] = useState<string>("all");
const TAB_MODALITIES = useMemo(() => MODALITIES.filter((m) => m.id !== "file"), []);
const hasFile = files.length > 0;
// If no file uploaded, show default layout (tool panel + dropzone)
@@ -127,54 +130,6 @@ export function HomePage() {
</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" aria-hidden="true" />
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
})}
</div>
{/* Full-width file preview */}
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
{files.length > 1 ? (
@@ -244,72 +199,52 @@ export function HomePage() {
</button>
</div>
{/* Quick actions */}
<div className="p-4 border-b border-border">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
{t.homePage.quickActions}
</h3>
<div className="grid grid-cols-2 gap-2">
{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 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-start"
>
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
})}
</div>
{/* Modality filter tabs */}
<div className="flex flex-wrap gap-1 px-4 pt-3 pb-2 border-b border-border sticky top-0 bg-background z-10">
<button
type="button"
onClick={() => setModalityFilter("all")}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
modalityFilter === "all"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<span>All</span>
</button>
{TAB_MODALITIES.map((m) => {
const Icon = ICON_MAP[m.icon] as React.ComponentType<{ className?: string }>;
const isActive = modalityFilter === m.id;
const label = m.id === "document" ? "Docs" : m.name;
return (
<button
key={m.id}
type="button"
onClick={() => setModalityFilter(m.id)}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
<span>{label}</span>
</button>
);
})}
</div>
{/* All tools by modality and category */}
{/* Tools by modality and category */}
<div className="p-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
{t.homePage.allTools}
</h3>
{MODALITIES.filter((m) => {
if (m.id === "file") return false;
if (modalityFilter !== "all") {
if (modalityFilter === "document") return m.id === "document";
return m.id === modalityFilter;
}
const key = m.id;
return TOOLS.some(
(tool) => tool.modality === key || (key === "document" && tool.modality === "file"),
+1 -1
View File
@@ -191,7 +191,7 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
editingTextId: null,
// --- Shape settings ---
shapeFill: "#3b82f6" as string | null,
shapeFill: "#E07832" as string | null,
shapeFillOpacity: 1,
shapeStroke: "#000000" as string | null,
shapeStrokeOpacity: 1,
+24 -20
View File
@@ -10,31 +10,35 @@
}
@theme {
--color-primary: #2563eb;
--color-primary: #E07832;
--color-primary-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-primary-light: #F09550;
--color-primary-dark: #C06520;
--color-primary-subtle: #FFF5ED;
--color-background: #FAFAF7;
--color-foreground: #1A1814;
--color-muted: #F3EEE6;
--color-muted-foreground: #6B6560;
--color-border: #DDD6CC;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-sidebar: #f8fafc;
--color-sidebar-foreground: #334155;
--color-accent: #3b82f6;
--color-destructive: #ef4444;
--color-card-foreground: #1A1814;
--color-sidebar: #F3EEE6;
--color-sidebar-foreground: #3D3832;
--color-accent: #F09550;
--color-destructive: #D44C47;
--color-success: #2D9D78;
}
.dark {
--color-background: #0f172a;
--color-foreground: #f8fafc;
--color-muted: #1e293b;
--color-muted-foreground: #94a3b8;
--color-border: #334155;
--color-card: #1e293b;
--color-card-foreground: #f8fafc;
--color-sidebar: #1e293b;
--color-sidebar-foreground: #cbd5e1;
--color-background: #1A1210;
--color-foreground: #F0EBE4;
--color-muted: #262018;
--color-muted-foreground: #9A908A;
--color-border: #382E26;
--color-card: #262018;
--color-card-foreground: #F0EBE4;
--color-sidebar: #262018;
--color-sidebar-foreground: #C5BAB0;
}
input[type="range"] {