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