diff --git a/apps/web/src/components/config-panel.tsx b/apps/web/src/components/config-panel.tsx
index bd8207e..6ee0b7e 100644
--- a/apps/web/src/components/config-panel.tsx
+++ b/apps/web/src/components/config-panel.tsx
@@ -5,7 +5,6 @@ import {
CardHeader,
CardTitle,
} from "@tiny-svg/ui/components/card";
-import { Input } from "@tiny-svg/ui/components/input";
import { Label } from "@tiny-svg/ui/components/label";
import { Switch } from "@tiny-svg/ui/components/switch";
import { useCallback, useEffect } from "react";
@@ -20,6 +19,9 @@ import {
import { useSvgStore } from "@/store/svg-store";
import { type ExportScale, useUiStore } from "@/store/ui-store";
import { ExportPanel } from "./export-panel";
+import { DeletePresetDialog } from "./presets/delete-preset-dialog";
+import { PresetEditorDialog } from "./presets/preset-editor-dialog";
+import { PresetList } from "./presets/preset-list";
type ConfigPanelProps = {
isCollapsed: boolean;
@@ -33,11 +35,8 @@ export function ConfigPanel({
className,
}: ConfigPanelProps) {
const {
- plugins,
globalSettings,
- togglePlugin,
updateGlobalSettings,
- resetPlugins,
compressedSvg,
fileName,
originalSvg,
@@ -52,23 +51,15 @@ export function ConfigPanel({
setExportDimensions,
} = useUiStore();
const { settings } = useIntlayer("optimize");
- const pluginLabels = useIntlayer("plugins");
// 提供默认值,防止服务器端渲染错误
const safeSettings = settings || {
title: "Settings",
global: {
- title: "Global Settings",
+ title: "Display Settings",
showOriginal: "Show original",
compareGzipped: "Compare gzipped",
prettifyMarkup: "Prettify markup",
- multipass: "Multipass",
- numberPrecision: "Number precision",
- transformPrecision: "Transform precision",
- },
- features: {
- title: "Features",
- resetAll: "Reset all",
},
export: {
title: "Export",
@@ -213,7 +204,14 @@ export function ConfigPanel({
- {/* Global Settings */}
+ {/* Presets */}
+
+
+
+
+
+
+ {/* Display Settings */}
@@ -264,91 +262,6 @@ export function ConfigPanel({
}
/>
-
-
-
- updateGlobalSettings({ multipass: checked })
- }
- />
-
-
-
-
- updateGlobalSettings({
- floatPrecision: Number.parseInt(e.target.value, 10),
- })
- }
- type="number"
- value={globalSettings.floatPrecision}
- />
-
-
-
-
- updateGlobalSettings({
- transformPrecision: Number.parseInt(e.target.value, 10),
- })
- }
- type="number"
- value={globalSettings.transformPrecision}
- />
-
-
-
-
- {/* Features */}
-
-
-
-
- {safeSettings.features.title}
-
-
-
-
-
- {plugins.map((plugin) => (
-
-
- togglePlugin(plugin.name)}
- />
-
- ))}
@@ -364,6 +277,10 @@ export function ConfigPanel({
onWidthChange={handleWidthChange}
/>
+
+ {/* Preset Dialogs */}
+
+
);
}
diff --git a/apps/web/src/components/presets/delete-preset-dialog.tsx b/apps/web/src/components/presets/delete-preset-dialog.tsx
new file mode 100644
index 0000000..dd141a1
--- /dev/null
+++ b/apps/web/src/components/presets/delete-preset-dialog.tsx
@@ -0,0 +1,69 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@tiny-svg/ui/components/alert-dialog";
+import { useIntlayer } from "react-intlayer";
+import { usePresetsStore } from "@/store/presets-store";
+
+export function DeletePresetDialog() {
+ const { presets: t } = useIntlayer("presets");
+ const {
+ deleteDialogPresetId,
+ closeDeleteDialog,
+ deletePreset,
+ getPresetById,
+ } = usePresetsStore();
+
+ const preset = deleteDialogPresetId
+ ? getPresetById(deleteDialogPresetId)
+ : null;
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ closeDeleteDialog();
+ }
+ };
+
+ const handleConfirm = () => {
+ if (deleteDialogPresetId) {
+ deletePreset(deleteDialogPresetId);
+ }
+ };
+
+ return (
+
+
+
+ {t.deleteDialog.title}
+
+ {String(t.deleteDialog.description).replace(
+ "{name}",
+ preset?.name || ""
+ )}
+
+
+
+
+ {t.deleteDialog.cancel}
+
+
+ {t.deleteDialog.confirm}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/presets/preset-card.tsx b/apps/web/src/components/presets/preset-card.tsx
new file mode 100644
index 0000000..bdfdd49
--- /dev/null
+++ b/apps/web/src/components/presets/preset-card.tsx
@@ -0,0 +1,157 @@
+import { Badge } from "@tiny-svg/ui/components/badge";
+import { Button } from "@tiny-svg/ui/components/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@tiny-svg/ui/components/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@tiny-svg/ui/components/tooltip";
+import { cn } from "@tiny-svg/ui/lib/utils";
+import { useIntlayer } from "react-intlayer";
+import type { Preset } from "@/types/preset";
+
+type PresetCardProps = {
+ preset: Preset;
+ isActive: boolean;
+ onApply: () => void;
+ onEdit: () => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ onPin: () => void;
+};
+
+export function PresetCard({
+ preset,
+ isActive,
+ onApply,
+ onEdit,
+ onDuplicate,
+ onDelete,
+ onPin,
+}: PresetCardProps) {
+ const { presets: t } = useIntlayer("presets");
+
+ return (
+
+
+
+
+ {preset.icon && {preset.icon}}
+ {preset.name}
+ {preset.isDefault && (
+
+ {t.systemBadge}
+
+ )}
+ {preset.pinned && (
+
+ )}
+
+
+ {preset.description && (
+
+ {preset.description}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {preset.pinned ? t.unpin : t.pin}
+
+
+
+
+
+
+
+ {t.edit}
+
+
+
+
+
+
+ {t.duplicate}
+
+
+ {!preset.isDefault && (
+
+
+
+
+ {t.delete}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/presets/preset-editor-dialog.tsx b/apps/web/src/components/presets/preset-editor-dialog.tsx
new file mode 100644
index 0000000..6999d1f
--- /dev/null
+++ b/apps/web/src/components/presets/preset-editor-dialog.tsx
@@ -0,0 +1,292 @@
+import { Button } from "@tiny-svg/ui/components/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@tiny-svg/ui/components/dialog";
+import { Input } from "@tiny-svg/ui/components/input";
+import { Label } from "@tiny-svg/ui/components/label";
+import { ScrollArea } from "@tiny-svg/ui/components/scroll-area";
+import { Separator } from "@tiny-svg/ui/components/separator";
+import { Switch } from "@tiny-svg/ui/components/switch";
+import { Textarea } from "@tiny-svg/ui/components/textarea";
+import { useEffect, useState } from "react";
+import { useIntlayer } from "react-intlayer";
+import {
+ defaultPresetGlobalSettings,
+ validatePreset,
+} from "@/lib/preset-utils";
+import { allSvgoPlugins, type SvgoPluginConfig } from "@/lib/svgo-plugins";
+import { usePresetsStore } from "@/store/presets-store";
+import type { PresetGlobalSettings } from "@/types/preset";
+
+export function PresetEditorDialog() {
+ const { presets: t } = useIntlayer("presets");
+ const pluginLabels = useIntlayer("plugins");
+
+ const {
+ isEditorOpen,
+ editorMode,
+ editingPresetId,
+ sourcePresetId,
+ presets,
+ closeEditor,
+ addPreset,
+ updatePreset,
+ getPresetById,
+ } = usePresetsStore();
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [plugins, setPlugins] = useState(allSvgoPlugins);
+ const [globalSettings, setGlobalSettings] = useState(
+ defaultPresetGlobalSettings
+ );
+ const [errors, setErrors] = useState([]);
+
+ // Load preset data when dialog opens
+ useEffect(() => {
+ if (!isEditorOpen) {
+ return;
+ }
+
+ if (editorMode === "edit" && editingPresetId) {
+ const preset = getPresetById(editingPresetId);
+ if (preset) {
+ setName(preset.name);
+ setDescription(preset.description);
+ setPlugins(preset.plugins);
+ setGlobalSettings(preset.globalSettings);
+ }
+ } else if (sourcePresetId) {
+ // Duplicating
+ const source = getPresetById(sourcePresetId);
+ if (source) {
+ setName(`${source.name} (Copy)`);
+ setDescription(source.description);
+ setPlugins(source.plugins);
+ setGlobalSettings(source.globalSettings);
+ }
+ } else {
+ // New preset with defaults
+ setName("");
+ setDescription("");
+ setPlugins(allSvgoPlugins);
+ setGlobalSettings(defaultPresetGlobalSettings);
+ }
+ setErrors([]);
+ }, [
+ isEditorOpen,
+ editorMode,
+ editingPresetId,
+ sourcePresetId,
+ getPresetById,
+ ]);
+
+ const handlePluginToggle = (pluginName: string) => {
+ setPlugins((prev) =>
+ prev.map((p) =>
+ p.name === pluginName ? { ...p, enabled: !p.enabled } : p
+ )
+ );
+ };
+
+ const handleResetPlugins = () => {
+ setPlugins(allSvgoPlugins);
+ };
+
+ const handleSave = () => {
+ const validation = validatePreset(
+ { id: editingPresetId || undefined, name },
+ presets
+ );
+
+ if (!validation.isValid) {
+ setErrors(validation.errors);
+ return;
+ }
+
+ if (editorMode === "edit" && editingPresetId) {
+ updatePreset(editingPresetId, {
+ name,
+ description,
+ plugins,
+ globalSettings,
+ });
+ } else {
+ addPreset({
+ name,
+ description,
+ plugins,
+ globalSettings,
+ });
+ }
+
+ closeEditor();
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ closeEditor();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/presets/preset-list.tsx b/apps/web/src/components/presets/preset-list.tsx
new file mode 100644
index 0000000..24369ec
--- /dev/null
+++ b/apps/web/src/components/presets/preset-list.tsx
@@ -0,0 +1,73 @@
+import { Button } from "@tiny-svg/ui/components/button";
+import { ScrollArea } from "@tiny-svg/ui/components/scroll-area";
+import { useIntlayer } from "react-intlayer";
+import { usePresetsStore } from "@/store/presets-store";
+import { useSvgStore } from "@/store/svg-store";
+import type { Preset } from "@/types/preset";
+import { PresetCard } from "./preset-card";
+
+export function PresetList() {
+ const { presets: t } = useIntlayer("presets");
+ const {
+ getSortedPresets,
+ activePresetId,
+ setActivePreset,
+ openEditor,
+ pinPreset,
+ openDeleteDialog,
+ } = usePresetsStore();
+ const { applyPreset } = useSvgStore();
+
+ const sortedPresets = getSortedPresets();
+
+ const handleApply = (preset: Preset) => {
+ setActivePreset(preset.id);
+ applyPreset(preset.plugins, preset.globalSettings);
+ };
+
+ const handleEdit = (preset: Preset) => {
+ openEditor("edit", preset.id);
+ };
+
+ const handleDuplicate = (preset: Preset) => {
+ openEditor("create", undefined, preset.id);
+ };
+
+ const handleDelete = (presetId: string) => {
+ openDeleteDialog(presetId);
+ };
+
+ return (
+
+
+
{t.title}
+
+
+
+
+
+ {sortedPresets.map((preset) => (
+
handleApply(preset)}
+ onDelete={() => handleDelete(preset.id)}
+ onDuplicate={() => handleDuplicate(preset)}
+ onEdit={() => handleEdit(preset)}
+ onPin={() => pinPreset(preset.id)}
+ preset={preset}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/contents/presets.content.ts b/apps/web/src/contents/presets.content.ts
new file mode 100644
index 0000000..dcffe62
--- /dev/null
+++ b/apps/web/src/contents/presets.content.ts
@@ -0,0 +1,218 @@
+import { type Dictionary, t } from "intlayer";
+
+const presetsContent: Dictionary = {
+ key: "presets",
+ content: {
+ presets: {
+ title: t({
+ en: "Presets",
+ zh: "预设",
+ ko: "프리셋",
+ de: "Voreinstellungen",
+ fr: "Préréglages",
+ }),
+ addNew: t({
+ en: "Add New",
+ zh: "添加",
+ ko: "추가",
+ de: "Neu hinzufügen",
+ fr: "Ajouter",
+ }),
+ systemBadge: t({
+ en: "System",
+ zh: "系统",
+ ko: "시스템",
+ de: "System",
+ fr: "Système",
+ }),
+ apply: t({
+ en: "Apply",
+ zh: "应用",
+ ko: "적용",
+ de: "Anwenden",
+ fr: "Appliquer",
+ }),
+ applied: t({
+ en: "Applied",
+ zh: "已应用",
+ ko: "적용됨",
+ de: "Angewendet",
+ fr: "Appliqué",
+ }),
+ edit: t({
+ en: "Edit",
+ zh: "编辑",
+ ko: "편집",
+ de: "Bearbeiten",
+ fr: "Modifier",
+ }),
+ duplicate: t({
+ en: "Duplicate",
+ zh: "复制",
+ ko: "복제",
+ de: "Duplizieren",
+ fr: "Dupliquer",
+ }),
+ delete: t({
+ en: "Delete",
+ zh: "删除",
+ ko: "삭제",
+ de: "Löschen",
+ fr: "Supprimer",
+ }),
+ pin: t({
+ en: "Pin",
+ zh: "置顶",
+ ko: "고정",
+ de: "Anheften",
+ fr: "Épingler",
+ }),
+ unpin: t({
+ en: "Unpin",
+ zh: "取消置顶",
+ ko: "고정 해제",
+ de: "Lösen",
+ fr: "Désépingler",
+ }),
+ editor: {
+ titleCreate: t({
+ en: "Create Preset",
+ zh: "创建预设",
+ ko: "프리셋 생성",
+ de: "Voreinstellung erstellen",
+ fr: "Créer un préréglage",
+ }),
+ titleEdit: t({
+ en: "Edit Preset",
+ zh: "编辑预设",
+ ko: "프리셋 편집",
+ de: "Voreinstellung bearbeiten",
+ fr: "Modifier le préréglage",
+ }),
+ description: t({
+ en: "Configure SVGO optimization settings for this preset",
+ zh: "配置此预设的 SVGO 优化设置",
+ ko: "이 프리셋의 SVGO 최적화 설정 구성",
+ de: "SVGO-Optimierungseinstellungen für diese Voreinstellung konfigurieren",
+ fr: "Configurer les paramètres d'optimisation SVGO pour ce préréglage",
+ }),
+ name: t({
+ en: "Name",
+ zh: "名称",
+ ko: "이름",
+ de: "Name",
+ fr: "Nom",
+ }),
+ namePlaceholder: t({
+ en: "Enter preset name",
+ zh: "输入预设名称",
+ ko: "프리셋 이름 입력",
+ de: "Voreinstellungsname eingeben",
+ fr: "Entrer le nom du préréglage",
+ }),
+ descriptionLabel: t({
+ en: "Description",
+ zh: "描述",
+ ko: "설명",
+ de: "Beschreibung",
+ fr: "Description",
+ }),
+ descriptionPlaceholder: t({
+ en: "Optional description",
+ zh: "可选描述",
+ ko: "선택적 설명",
+ de: "Optionale Beschreibung",
+ fr: "Description optionnelle",
+ }),
+ globalSettings: t({
+ en: "Compression Settings",
+ zh: "压缩设置",
+ ko: "압축 설정",
+ de: "Komprimierungseinstellungen",
+ fr: "Paramètres de compression",
+ }),
+ multipass: t({
+ en: "Multipass",
+ zh: "多次优化",
+ ko: "다중 패스",
+ de: "Mehrfachdurchlauf",
+ fr: "Passes multiples",
+ }),
+ floatPrecision: t({
+ en: "Number Precision",
+ zh: "数字精度",
+ ko: "숫자 정밀도",
+ de: "Zahlenpräzision",
+ fr: "Précision numérique",
+ }),
+ transformPrecision: t({
+ en: "Transform Precision",
+ zh: "变换精度",
+ ko: "변환 정밀도",
+ de: "Transformationspräzision",
+ fr: "Précision de transformation",
+ }),
+ plugins: t({
+ en: "SVGO Plugins",
+ zh: "SVGO 插件",
+ ko: "SVGO 플러그인",
+ de: "SVGO-Plugins",
+ fr: "Plugins SVGO",
+ }),
+ resetPlugins: t({
+ en: "Reset",
+ zh: "重置",
+ ko: "재설정",
+ de: "Zurücksetzen",
+ fr: "Réinitialiser",
+ }),
+ cancel: t({
+ en: "Cancel",
+ zh: "取消",
+ ko: "취소",
+ de: "Abbrechen",
+ fr: "Annuler",
+ }),
+ save: t({
+ en: "Save",
+ zh: "保存",
+ ko: "저장",
+ de: "Speichern",
+ fr: "Enregistrer",
+ }),
+ },
+ deleteDialog: {
+ title: t({
+ en: "Delete Preset?",
+ zh: "删除预设?",
+ ko: "프리셋을 삭제하시겠습니까?",
+ de: "Voreinstellung löschen?",
+ fr: "Supprimer le préréglage ?",
+ }),
+ description: t({
+ en: 'Are you sure you want to delete "{name}"? This action cannot be undone.',
+ zh: '确定要删除"{name}"吗?此操作无法撤销。',
+ ko: '"{name}"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
+ de: 'Möchten Sie "{name}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
+ fr: 'Êtes-vous sûr de vouloir supprimer "{name}" ? Cette action est irréversible.',
+ }),
+ cancel: t({
+ en: "Cancel",
+ zh: "取消",
+ ko: "취소",
+ de: "Abbrechen",
+ fr: "Annuler",
+ }),
+ confirm: t({
+ en: "Delete",
+ zh: "删除",
+ ko: "삭제",
+ de: "Löschen",
+ fr: "Supprimer",
+ }),
+ },
+ },
+ },
+};
+
+export default presetsContent;
diff --git a/apps/web/src/hooks/use-optimize-page.ts b/apps/web/src/hooks/use-optimize-page.ts
index 97e8b74..3e6cd6c 100644
--- a/apps/web/src/hooks/use-optimize-page.ts
+++ b/apps/web/src/hooks/use-optimize-page.ts
@@ -18,6 +18,7 @@ import {
import type { HistoryEntry } from "@/lib/svg-history-storage";
import { getComponentName } from "@/lib/svg-to-code";
import { calculateCompressionRate } from "@/lib/svgo-config";
+import { usePresetsStore } from "@/store/presets-store";
import { useSvgStore } from "@/store/svg-store";
import { useUiStore } from "@/store/ui-store";
@@ -63,8 +64,15 @@ export function useOptimizePage() {
clearAll,
} = useSvgHistory();
+ const { initializePresets } = usePresetsStore();
+
const { messages, ui } = useIntlayer("optimize");
+ // Initialize presets store on mount
+ useEffect(() => {
+ initializePresets();
+ }, [initializePresets]);
+
// Ensure globalSettings has default values for SSR
const safeGlobalSettings = globalSettings || defaultGlobalSettings;
diff --git a/apps/web/src/lib/preset-storage.ts b/apps/web/src/lib/preset-storage.ts
new file mode 100644
index 0000000..9c3138e
--- /dev/null
+++ b/apps/web/src/lib/preset-storage.ts
@@ -0,0 +1,62 @@
+import type { Preset } from "@/types/preset";
+
+const STORAGE_KEY = "tiny-svg-presets";
+
+/**
+ * Load custom presets from localStorage
+ * System presets are not stored, only custom presets
+ */
+export function loadPresets(): Preset[] {
+ if (typeof window === "undefined") {
+ return [];
+ }
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return stored ? JSON.parse(stored) : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Save custom presets to localStorage
+ * Filters out system presets (isDefault: true)
+ */
+export function savePresets(presets: Preset[]): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const customPresets = presets.filter((p) => !p.isDefault);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(customPresets));
+}
+
+/**
+ * Save a single preset to localStorage
+ * Handles both new presets and updates
+ */
+export function savePreset(preset: Preset): void {
+ if (preset.isDefault) {
+ return; // Don't persist system presets
+ }
+
+ const presets = loadPresets();
+ const index = presets.findIndex((p) => p.id === preset.id);
+
+ if (index >= 0) {
+ presets[index] = preset;
+ } else {
+ presets.push(preset);
+ }
+
+ savePresets(presets);
+}
+
+/**
+ * Delete a preset from localStorage by ID
+ */
+export function deletePresetFromStorage(id: string): void {
+ const presets = loadPresets().filter((p) => p.id !== id);
+ savePresets(presets);
+}
diff --git a/apps/web/src/lib/preset-utils.ts b/apps/web/src/lib/preset-utils.ts
new file mode 100644
index 0000000..dbd8f5c
--- /dev/null
+++ b/apps/web/src/lib/preset-utils.ts
@@ -0,0 +1,148 @@
+import { compressionPresets, getPresetConfig } from "@tiny-svg/svg";
+import type { Config as SvgoConfig } from "svgo";
+import { allSvgoPlugins, type SvgoPluginConfig } from "@/lib/svgo-plugins";
+import type { Preset, PresetGlobalSettings } from "@/types/preset";
+
+/**
+ * Default compression-related global settings for presets
+ */
+export const defaultPresetGlobalSettings: PresetGlobalSettings = {
+ multipass: true,
+ floatPrecision: 2,
+ transformPrecision: 4,
+};
+
+/**
+ * Get system presets from @tiny-svg/svg package
+ */
+export function getDefaultPresets(): Preset[] {
+ return compressionPresets.map((preset, index) => {
+ const config = getPresetConfig(preset.id);
+ return {
+ id: preset.id,
+ name: preset.name,
+ description: preset.description,
+ icon: preset.icon,
+ isDefault: true,
+ plugins: config?.plugins || allSvgoPlugins,
+ globalSettings: defaultPresetGlobalSettings,
+ svgoConfig: (config?.config || { plugins: [] }) as SvgoConfig,
+ createdAt: Date.now() - index * 1000,
+ updatedAt: Date.now() - index * 1000,
+ };
+ });
+}
+
+/**
+ * Generate a unique preset ID from name
+ */
+export function createPresetId(name: string): string {
+ const timestamp = Date.now();
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ return `custom-${slug}-${timestamp}`;
+}
+
+/**
+ * Validate preset data
+ */
+export function validatePreset(
+ preset: Partial,
+ existingPresets: Preset[]
+): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ if (!preset.name?.trim()) {
+ errors.push("Preset name is required");
+ } else if (preset.name.length > 50) {
+ errors.push("Preset name must be 50 characters or less");
+ }
+
+ // Check for duplicate names (excluding current preset when editing)
+ const duplicate = existingPresets.find(
+ (p) => p.id !== preset.id && p.name === preset.name?.trim()
+ );
+ if (duplicate) {
+ errors.push(`A preset named "${preset.name}" already exists`);
+ }
+
+ return { isValid: errors.length === 0, errors };
+}
+
+/**
+ * Check if preset can be deleted (only custom presets can be deleted)
+ */
+export function canDeletePreset(preset: Preset): boolean {
+ return !preset.isDefault;
+}
+
+/**
+ * Sort presets: pinned first, then system presets, then by updated time
+ */
+export function sortPresets(presets: Preset[]): Preset[] {
+ return [...presets].sort((a, b) => {
+ // Pinned first
+ if (a.pinned && !b.pinned) {
+ return -1;
+ }
+ if (!a.pinned && b.pinned) {
+ return 1;
+ }
+ // Then default presets
+ if (a.isDefault && !b.isDefault) {
+ return -1;
+ }
+ if (!a.isDefault && b.isDefault) {
+ return 1;
+ }
+ // Then by updated time (newest first)
+ return b.updatedAt - a.updatedAt;
+ });
+}
+
+/**
+ * Create a duplicate of a preset
+ */
+export function duplicatePreset(preset: Preset): Preset {
+ return {
+ ...preset,
+ id: createPresetId(`${preset.name} Copy`),
+ name: `${preset.name} (Copy)`,
+ isDefault: false,
+ pinned: false,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+}
+
+/**
+ * Build SVGO config from plugins and global settings
+ */
+export function buildSvgoConfig(
+ plugins: SvgoPluginConfig[],
+ globalSettings: PresetGlobalSettings
+): SvgoConfig {
+ const enabledPlugins = plugins
+ .filter((p) => p.enabled)
+ .map((p) => {
+ if (p.params) {
+ return { name: p.name, params: p.params };
+ }
+ return p.name;
+ });
+
+ return {
+ multipass: globalSettings.multipass,
+ floatPrecision: globalSettings.floatPrecision,
+ plugins: enabledPlugins,
+ } as SvgoConfig;
+}
+
+/**
+ * Get plugin count from preset
+ */
+export function getPresetPluginCount(preset: Preset): number {
+ return preset.plugins.filter((p) => p.enabled).length;
+}
diff --git a/apps/web/src/store/presets-store.ts b/apps/web/src/store/presets-store.ts
new file mode 100644
index 0000000..49f36c4
--- /dev/null
+++ b/apps/web/src/store/presets-store.ts
@@ -0,0 +1,165 @@
+import { create } from "zustand";
+import {
+ deletePresetFromStorage,
+ loadPresets,
+ savePresets,
+} from "@/lib/preset-storage";
+import {
+ buildSvgoConfig,
+ createPresetId,
+ getDefaultPresets,
+ sortPresets,
+} from "@/lib/preset-utils";
+import type { Preset, PresetCreateInput } from "@/types/preset";
+
+type EditorMode = "create" | "edit";
+
+type PresetsState = {
+ presets: Preset[];
+ activePresetId: string;
+ isEditorOpen: boolean;
+ editorMode: EditorMode;
+ editingPresetId: string | null;
+ sourcePresetId: string | null; // For duplication
+ deleteDialogPresetId: string | null;
+};
+
+type PresetsActions = {
+ initializePresets: () => void;
+ addPreset: (input: PresetCreateInput) => Preset;
+ updatePreset: (id: string, input: PresetCreateInput) => void;
+ deletePreset: (id: string) => void;
+ pinPreset: (id: string) => void;
+ setActivePreset: (id: string) => void;
+ openEditor: (mode: EditorMode, presetId?: string, sourceId?: string) => void;
+ closeEditor: () => void;
+ openDeleteDialog: (presetId: string) => void;
+ closeDeleteDialog: () => void;
+ getPresetById: (id: string) => Preset | undefined;
+ getSortedPresets: () => Preset[];
+};
+
+const initialState: PresetsState = {
+ presets: [],
+ activePresetId: "default",
+ isEditorOpen: false,
+ editorMode: "create",
+ editingPresetId: null,
+ sourcePresetId: null,
+ deleteDialogPresetId: null,
+};
+
+export const usePresetsStore = create(
+ (set, get) => ({
+ ...initialState,
+
+ initializePresets: () => {
+ const defaultPresets = getDefaultPresets();
+ const customPresets = loadPresets();
+ set({ presets: [...defaultPresets, ...customPresets] });
+ },
+
+ addPreset: (input: PresetCreateInput) => {
+ const now = Date.now();
+ const svgoConfig = buildSvgoConfig(input.plugins, input.globalSettings);
+ const newPreset: Preset = {
+ id: createPresetId(input.name),
+ name: input.name.trim(),
+ description: input.description || "",
+ icon: input.icon,
+ isDefault: false,
+ plugins: input.plugins,
+ globalSettings: input.globalSettings,
+ svgoConfig,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ set((state) => {
+ const updated = [...state.presets, newPreset];
+ savePresets(updated);
+ return { presets: updated };
+ });
+
+ return newPreset;
+ },
+
+ updatePreset: (id: string, input: PresetCreateInput) => {
+ set((state) => {
+ const svgoConfig = buildSvgoConfig(input.plugins, input.globalSettings);
+ const updated = state.presets.map((p) =>
+ p.id === id
+ ? {
+ ...p,
+ name: input.name.trim(),
+ description: input.description || "",
+ icon: input.icon,
+ plugins: input.plugins,
+ globalSettings: input.globalSettings,
+ svgoConfig,
+ updatedAt: Date.now(),
+ }
+ : p
+ );
+ savePresets(updated);
+ return { presets: updated };
+ });
+ },
+
+ deletePreset: (id: string) => {
+ set((state) => {
+ const updated = state.presets.filter((p) => p.id !== id);
+ deletePresetFromStorage(id);
+ return {
+ presets: updated,
+ activePresetId:
+ state.activePresetId === id ? "default" : state.activePresetId,
+ deleteDialogPresetId: null,
+ };
+ });
+ },
+
+ pinPreset: (id: string) => {
+ set((state) => {
+ const updated = state.presets.map((p) =>
+ p.id === id ? { ...p, pinned: !p.pinned, updatedAt: Date.now() } : p
+ );
+ savePresets(updated);
+ return { presets: updated };
+ });
+ },
+
+ setActivePreset: (id: string) => {
+ set({ activePresetId: id });
+ },
+
+ openEditor: (mode: EditorMode, presetId?: string, sourceId?: string) => {
+ set({
+ isEditorOpen: true,
+ editorMode: mode,
+ editingPresetId: presetId || null,
+ sourcePresetId: sourceId || null,
+ });
+ },
+
+ closeEditor: () => {
+ set({
+ isEditorOpen: false,
+ editingPresetId: null,
+ sourcePresetId: null,
+ });
+ },
+
+ openDeleteDialog: (presetId: string) => {
+ set({ deleteDialogPresetId: presetId });
+ },
+
+ closeDeleteDialog: () => {
+ set({ deleteDialogPresetId: null });
+ },
+
+ getPresetById: (id: string) => get().presets.find((p) => p.id === id),
+
+ getSortedPresets: () => sortPresets(get().presets),
+ })
+);
diff --git a/apps/web/src/store/svg-store.ts b/apps/web/src/store/svg-store.ts
index 1344bce..ca92b86 100644
--- a/apps/web/src/store/svg-store.ts
+++ b/apps/web/src/store/svg-store.ts
@@ -6,6 +6,7 @@ import {
type SvgoGlobalSettings,
type SvgoPluginConfig,
} from "@/lib/svgo-plugins";
+import type { PresetGlobalSettings } from "@/types/preset";
// Note: SvgoConfig type is imported but SVGO library is NOT bundled
// SVGO is only used in workers (svgo.worker.ts)
@@ -36,6 +37,10 @@ type SvgActions = {
transformedOriginal: string,
transformedCompressed: string
) => void;
+ applyPreset: (
+ plugins: SvgoPluginConfig[],
+ presetGlobalSettings: PresetGlobalSettings
+ ) => void;
};
const defaultSvgoConfig: SvgoConfig = {
@@ -89,4 +94,14 @@ export const useSvgStore = create((set) => ({
originalSvg: transformedOriginal,
compressedSvg: transformedCompressed,
}),
+ applyPreset: (plugins, presetGlobalSettings) =>
+ set((state) => ({
+ plugins,
+ globalSettings: {
+ ...state.globalSettings,
+ multipass: presetGlobalSettings.multipass,
+ floatPrecision: presetGlobalSettings.floatPrecision,
+ transformPrecision: presetGlobalSettings.transformPrecision,
+ },
+ })),
}));
diff --git a/apps/web/src/types/preset.ts b/apps/web/src/types/preset.ts
new file mode 100644
index 0000000..aeeb729
--- /dev/null
+++ b/apps/web/src/types/preset.ts
@@ -0,0 +1,38 @@
+import type { Config as SvgoConfig } from "svgo";
+import type { SvgoPluginConfig } from "@/lib/svgo-plugins";
+
+/**
+ * Compression-related global settings that are stored in presets
+ * (Display settings like showOriginal, compareGzipped are kept in ui-store)
+ */
+export type PresetGlobalSettings = {
+ multipass: boolean;
+ floatPrecision: number;
+ transformPrecision: number;
+};
+
+export type Preset = {
+ id: string;
+ name: string;
+ description: string;
+ icon?: string;
+ isDefault: boolean; // true = system preset from @tiny-svg/svg
+ pinned?: boolean;
+ svgoConfig: SvgoConfig;
+ plugins: SvgoPluginConfig[];
+ globalSettings: PresetGlobalSettings;
+ createdAt: number;
+ updatedAt: number;
+};
+
+export type PresetCreateInput = {
+ name: string;
+ description?: string;
+ icon?: string;
+ plugins: SvgoPluginConfig[];
+ globalSettings: PresetGlobalSettings;
+};
+
+export type PresetUpdateInput = PresetCreateInput & {
+ id: string;
+};
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 4690a97..211b90e 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -14,32 +14,18 @@
"composite": true,
"target": "ES2022",
"module": "ESNext",
- "lib": [
- "ES2022",
- "DOM",
- "DOM.Iterable"
- ],
- "types": [
- "vite/client"
- ],
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
- "@/*": [
- "./src/*"
- ],
- "@content/*": [
- "./content/*"
- ],
- "content-collections": [
- "./.content-collections/generated"
- ],
- "@workspace/ui/*": [
- "../../packages/ui/src/*"
- ]
+ "@/*": ["./src/*"],
+ "@content/*": ["./content/*"],
+ "content-collections": ["./.content-collections/generated"],
+ "@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index f8d0d59..77b05a7 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -4,13 +4,8 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
- "@/*": [
- "./src/*"
- ]
+ "@/*": ["./src/*"]
}
},
- "include": [
- "src/**/*.ts",
- "src/**/*.tsx"
- ]
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
}