feat(web): apply Otter Orange design system and UI improvements

Adopt the landing page's Otter Orange (#E07832) design system across
the web app, replacing the generic blue (#2563eb) theme. Light mode
uses warm cream/stone neutrals, dark mode uses deep brown tones.

UI changes:
- Remove Quick Actions section from home page
- Add modality filter tabs to file-uploaded view
- Remove colored left-border accents from tool panels and grid cards
- Use standard dropzone for pdf-to-image (custom-results mode)
- Fix PDF document viewer to use ArrayBuffer instead of blob URL
- Fix missing FileImage import in dropzone
This commit is contained in:
SnapOtter
2026-06-14 14:06:25 +08:00
parent b8b6b0a44a
commit bcd59c20c7
16 changed files with 110 additions and 222 deletions
@@ -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>
)}