diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9859d8ca..0b338e7f 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1256,6 +1256,26 @@ pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result, S .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn create_style_pack_from_template( + coord: CoordinatorState<'_>, + app: AppHandle, + template: StylePack, +) -> Result { + log::info!( + "[style-pack] command create_from_template name={} base_mode={:?}", + template.name, + template.base_mode + ); + let created = coord + .style_packs() + .create_from_template(template) + .map_err(|e| e.to_string())?; + let prefs = coord.prefs().get(); + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + Ok(created) +} + #[tauri::command] pub fn save_style_pack( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 420c4ba8..417e0a4c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -301,6 +301,7 @@ pub fn run() { commands::inject_hotkey_click_for_dev, commands::repolish, commands::list_style_packs, + commands::create_style_pack_from_template, commands::save_style_pack, commands::preview_style_pack_runtime, commands::set_active_style_pack, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f4722a12..854b7d98 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1023,6 +1023,37 @@ impl StylePackStore { .ok_or_else(|| anyhow!("no enabled style pack available")) } + /// 从模板新建一个 imported 风格包("+"按钮路径)。 + /// 跟 ZIP 导入不同:没有 manifest.json、没有 assets,纯空白模板。 + /// 调用方负责 set `prefs.active_style_pack_id` 等高层 wiring(这里只管落盘)。 + pub fn create_from_template(&self, template: StylePack) -> Result { + let mut packs = self.state.lock(); + let base_id = if template.id.trim().is_empty() { + format!("imported-{}", Uuid::new_v4().simple()) + } else { + template.id.clone() + }; + let assigned_id = unique_imported_style_pack_id(&packs, &base_id); + let now = Utc::now().to_rfc3339(); + let mut pack = template; + pack.id = assigned_id; + pack.kind = StylePackKind::Imported; + pack.created_at = Some(now.clone()); + pack.updated_at = Some(now); + pack.active = false; + pack.enabled = true; + packs.push(pack.clone()); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] created from template id={} base_mode={:?} prompt_chars={} examples={}", + pack.id, + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len() + ); + Ok(pack) + } + pub fn upsert(&self, incoming: StylePack) -> Result { let mut packs = self.state.lock(); let index = packs diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f5ee3c72..f4e017e7 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -384,17 +384,6 @@ export const en: typeof zhCN = { llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: 'Migrated', - styleSystemPromptMovedDesc: 'Full system prompts are no longer edited per mode on the Settings page. They now live in the Style Pack detail panel on the Style page.', - styleSystemPromptMovedHint: 'To change runtime prompts, examples, tags, or ZIP import/export, use the Style page.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 162d47a9..9f89fc58 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -386,17 +386,6 @@ export const ja: typeof zhCN = { llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: '移行済み', - styleSystemPromptMovedDesc: '完全な system prompt は設定ページでモード別に直接編集せず、「スタイル」ページの Style Pack 詳細パネルに集約されました。', - styleSystemPromptMovedHint: '実行時 prompt、例、タグ、ZIP のインポート/エクスポートを変更する場合は「スタイル」ページで操作してください。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 32890cd4..60461e87 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -389,17 +389,6 @@ export const ko: typeof zhCN = { asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: '마이그레이션됨', - styleSystemPromptMovedDesc: '전체 system prompt는 더 이상 설정 페이지에서 모드별로 직접 편집하지 않고, 스타일 페이지의 Style Pack 상세 패널로 통합되었습니다.', - styleSystemPromptMovedHint: '런타임 prompt, 예시, 태그 또는 ZIP 가져오기/내보내기를 변경하려면 스타일 페이지에서 작업하세요.', presets: { ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 5d75fcae..1789698e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -382,17 +382,6 @@ export const zhCN = { llmProviderDesc: '选择后将自动填入 Base URL 默认值。', credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', - styleSystemPromptTitle: '润色 System Prompt', - styleSystemPromptDesc: '每个内置风格都可以直接编辑完整 system prompt。保存后,实时润色和 History 里的 repolish 会共用这一套提示词。', - styleSystemPromptPlaceholder: '输入该风格的完整 system prompt', - styleSystemPromptHint: '这里编辑的是完整提示词,不再只是附加片段。按 Ctrl/Cmd+Enter 也可保存。', - styleSystemPromptSave: '保存 Prompt', - styleSystemPromptReset: '恢复默认', - styleSystemPromptDirty: '未保存', - styleSystemPromptSaveFailed: '保存 System Prompt 失败:{{error}}', - styleSystemPromptMovedBadge: '已迁移', - styleSystemPromptMovedDesc: '完整 system prompt 不再在设置页里按模式硬编码编辑,而是统一收敛到「风格」页的 Style Pack 详情面板。', - styleSystemPromptMovedHint: '如果你要改运行时 prompt、示例、标签或导入导出 ZIP,请去「风格」页操作。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e694f04c..d3820b8a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -384,17 +384,6 @@ export const zhTW: typeof zhCN = { llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', - styleSystemPromptTitle: '潤色 System Prompt', - styleSystemPromptDesc: '每個內建風格都可以直接編輯完整 system prompt。儲存後,即時潤色和 History 裡的 repolish 會共用這一套提示詞。', - styleSystemPromptPlaceholder: '輸入該風格的完整 system prompt', - styleSystemPromptHint: '這裡編輯的是完整提示詞,不再只是附加片段。按 Ctrl/Cmd+Enter 也可儲存。', - styleSystemPromptSave: '儲存 Prompt', - styleSystemPromptReset: '恢復預設', - styleSystemPromptDirty: '未儲存', - styleSystemPromptSaveFailed: '儲存 System Prompt 失敗:{{error}}', - styleSystemPromptMovedBadge: '已遷移', - styleSystemPromptMovedDesc: '完整 system prompt 不再在設定頁裡按模式硬編碼編輯,而是統一收斂到「風格」頁的 Style Pack 詳情面板。', - styleSystemPromptMovedHint: '如果你要改執行時 prompt、範例、標籤或匯入匯出 ZIP,請去「風格」頁操作。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a3fd0c09..8faa5063 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -689,6 +689,22 @@ export function saveStylePack(stylePack: StylePack): Promise { }); } +export function createStylePackFromTemplate(template: StylePack): Promise { + return invokeOrMock('create_style_pack_from_template', { template }, () => { + const created: StylePack = { + ...cloneStylePack(template), + id: `imported-mock-${Date.now()}`, + kind: 'imported', + active: false, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + mockStylePacks = [...mockStylePacks, created]; + return cloneStylePack(created); + }); +} + export function previewStylePackRuntime(stylePack: StylePack): Promise { return invokeOrMock('preview_style_pack_runtime', { stylePack }, () => composeMockStylePackRuntimeDiagnostics(stylePack)); } diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 427bc793..e89fb27b 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1607,34 +1607,6 @@ function ProvidersSection() { setLlmModelRevision(v => v + 1)} /> - -
-
{t('settings.providers.styleSystemPromptTitle')}
-
- {t('settings.providers.styleSystemPromptDesc')} -
-
-
-
- {t('settings.providers.styleSystemPromptMovedBadge')} - {t('settings.providers.styleSystemPromptTitle')} -
-
- {t('settings.providers.styleSystemPromptMovedDesc')} -
-
- {t('settings.providers.styleSystemPromptMovedHint')} -
-
-
-
{t('settings.providers.asrTitle')}
diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 1b34406a..b69232e8 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -1,6 +1,7 @@ -import { type CSSProperties, useEffect, useState } from 'react'; +import { type CSSProperties, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { + createStylePackFromTemplate, deleteStylePack, exportStylePackToZip, importStylePackFromZip, @@ -10,11 +11,11 @@ import { resetBuiltinStylePack, saveStylePack, setActiveStylePack, - setStylePackEnabled, } from '../lib/ipc'; import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnostics } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; import { Icon } from '../components/Icon'; +import { SavedToast, type SaveToastState } from '../components/SavedToast'; type BusyAction = | 'loading' @@ -22,11 +23,31 @@ type BusyAction = | 'importing' | 'exporting' | 'activating' - | 'toggling' | 'resetting' | 'deleting' + | 'creating' | null; +const BUILTIN_RAW_ID = 'builtin.raw'; +const BUILTIN_BODY_ORDER = ['builtin.light', 'builtin.structured', 'builtin.formal']; + +const NEW_PACK_TEMPLATE_BASE: Omit = { + name: '未命名风格', + description: '简短描述这个风格的使用场景。', + author: null, + version: '1.0.0', + kind: 'imported', + baseMode: 'light', + prompt: '你是 OpenLess 的润色助手。请将口语化的转写整理为顺畅、自然、可直接发送的文字,但不要扩写事实。', + examples: [], + tags: [], + iconPath: null, + enabled: true, + active: false, + recommendedModel: null, + compatibleAppVersion: null, +}; + function clonePack(pack: StylePack): StylePack { return { ...pack, @@ -86,11 +107,7 @@ export function Style() { builtin: isEnglish ? 'Built-in' : '内置', imported: isEnglish ? 'Imported' : '导入', active: isEnglish ? 'Active' : '当前', - enabled: isEnglish ? 'In Rotation' : '已加入轮换', - disabled: isEnglish ? 'Out of Rotation' : '未加入轮换', activate: isEnglish ? 'Activate' : '激活', - enable: isEnglish ? 'Rotation ON' : '轮换 ON', - disable: isEnglish ? 'Rotation OFF' : '轮换 OFF', edit: isEnglish ? 'Edit' : '编辑', closeEditor: isEnglish ? 'Close' : '关闭', unsaved: isEnglish ? 'Unsaved' : '未保存', @@ -99,15 +116,17 @@ export function Style() { ? 'Browse and switch packs.' : '浏览和切换风格包。', listCount: (count: number) => (isEnglish ? `${count} packs` : `${count} 个风格包`), + addPackTileTitle: isEnglish ? 'New Pack' : '新建风格包', + addPackTileHint: isEnglish ? 'Start from a blank template.' : '从空白模板开始。', + createSuccess: isEnglish ? 'New pack created.' : '已创建新风格包', + createFailed: (message: string) => (isEnglish ? `Failed to create pack: ${message}` : `创建风格包失败:${message}`), + builtinPackEditLabel: (name: string) => (isEnglish ? `Edit "${name}"` : `编辑「${name}」`), save: isEnglish ? 'Save' : '保存', revert: isEnglish ? 'Revert' : '撤销', saveSuccess: isEnglish ? 'Style pack saved.' : '风格包已保存', saveFailed: (message: string) => (isEnglish ? `Failed to save style pack: ${message}` : `保存风格包失败:${message}`), activateSuccess: (name: string) => (isEnglish ? `Set "${name}" as current.` : `已将“${name}”设为当前风格`), activateFailed: (message: string) => (isEnglish ? `Failed to set current style pack: ${message}` : `设为当前风格失败:${message}`), - enableSuccess: (name: string) => (isEnglish ? `Added "${name}" to rotation.` : `已将“${name}”加入轮换`), - disableSuccess: (name: string) => (isEnglish ? `Removed "${name}" from rotation.` : `已将“${name}”移出轮换`), - toggleFailed: (message: string) => (isEnglish ? `Failed to change rotation status: ${message}` : `切换轮换状态失败:${message}`), importSuccess: (name: string) => (isEnglish ? `Imported "${name}".` : `已导入“${name}”`), importFailed: (message: string) => (isEnglish ? `Failed to import ZIP: ${message}` : `导入 ZIP 失败:${message}`), exportSuccess: (path: string) => (isEnglish ? `Exported to ${path}` : `已导出到 ${path}`), @@ -122,12 +141,6 @@ export function Style() { : `确定删除“${name}”吗?删除后无法恢复。`), deleteSuccess: (name: string) => (isEnglish ? `Deleted "${name}".` : `已删除“${name}”`), deleteFailed: (message: string) => (isEnglish ? `Failed to delete pack: ${message}` : `删除风格包失败:${message}`), - summaryBuiltin: isEnglish ? 'Built-in Packs' : '内置风格', - summaryBuiltinHint: isEnglish ? 'Default product semantics with one-click reset.' : '跟随产品默认语义,可一键重置到官方基线。', - summaryImported: isEnglish ? 'Imported Packs' : '导入风格', - summaryImportedHint: isEnglish ? 'Installed from ZIP and fully portable.' : '来自 ZIP 包,可启用、编辑、导出和删除。', - summaryEnabled: isEnglish ? 'In Rotation' : '已加入轮换', - summaryCurrent: (name: string) => (isEnglish ? `Current: ${name}` : `当前启用:${name}`), summaryCurrentEmpty: isEnglish ? 'No pack selected yet' : '还没有选中风格包', editorTitle: isEnglish ? 'Edit Pack' : '编辑风格', editorDesc: isEnglish @@ -136,7 +149,6 @@ export function Style() { metaTitle: isEnglish ? 'Installation Info' : '安装信息', metaSource: isEnglish ? 'Source' : '来源', metaBaseMode: isEnglish ? 'Base Mode' : '基础模式', - metaStatus: isEnglish ? 'Rotation' : '轮换状态', metaUpdatedAt: isEnglish ? 'Updated' : '更新时间', fieldName: isEnglish ? 'Name' : '名称', fieldAuthor: isEnglish ? 'Author' : '作者', @@ -194,18 +206,42 @@ export function Style() { }; const [packs, setPacks] = useState([]); + const [selectedId, setSelectedId] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState('loading'); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); + const [saveState, setSaveState] = useState('idle'); + const [saveMessage, setSaveMessage] = useState(''); + const statusTimer = useRef(null); const [editorOpen, setEditorOpen] = useState(false); + const [editorClosing, setEditorClosing] = useState(false); + const editorCloseTimer = useRef(null); const [runtimePreview, setRuntimePreview] = useState(null); const [runtimePreviewError, setRuntimePreviewError] = useState(null); + useEffect(() => () => { + if (statusTimer.current !== null) window.clearTimeout(statusTimer.current); + if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current); + }, []); + + const showSaveStatus = (state: SaveToastState, message: string, temporary = false) => { + if (statusTimer.current !== null) { + window.clearTimeout(statusTimer.current); + statusTimer.current = null; + } + setSaveState(state); + setSaveMessage(message); + if (temporary) { + statusTimer.current = window.setTimeout(() => { + setSaveState('idle'); + setSaveMessage(''); + statusTimer.current = null; + }, 1600); + } + }; + const loadPacks = async (preferredId?: string | null) => { setBusy('loading'); - setError(null); try { const next = await listStylePacks(); setPacks(next); @@ -216,7 +252,7 @@ export function Style() { null; setSelectedId(nextSelectedId); } catch (loadError) { - setError(copy.loadFailed(String(loadError))); + showSaveStatus('failed', copy.loadFailed(String(loadError))); } finally { setBusy(null); } @@ -248,6 +284,12 @@ export function Style() { const selectedPack = packs.find(pack => pack.id === selectedId) ?? null; const activePack = packs.find(pack => pack.active) ?? null; + const rawPack = packs.find(pack => pack.id === BUILTIN_RAW_ID) ?? null; + const otherBuiltinPacks = packs + .filter(pack => pack.kind === 'builtin' && pack.id !== BUILTIN_RAW_ID) + .sort((a, b) => BUILTIN_BODY_ORDER.indexOf(a.id) - BUILTIN_BODY_ORDER.indexOf(b.id)); + const importedPacks = packs.filter(pack => pack.kind === 'imported'); + const bodyPacks = [...otherBuiltinPacks, ...importedPacks]; const builtinCount = packs.filter(pack => pack.kind === 'builtin').length; const importedCount = packs.filter(pack => pack.kind === 'imported').length; const enabledCount = packs.filter(pack => pack.enabled).length; @@ -284,8 +326,6 @@ export function Style() { const focusPack = (packId: string) => { setSelectedId(packId); - setNotice(null); - setError(null); }; const discardDraftChanges = () => { @@ -294,14 +334,26 @@ export function Style() { } }; + const startEditorClose = () => { + if (editorClosing) return; + setEditorClosing(true); + if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = window.setTimeout(() => { + setEditorOpen(false); + setEditorClosing(false); + editorCloseTimer.current = null; + }, 200); + }; + const closeEditor = () => { + if (editorClosing) return; if (dirty) { if (!window.confirm(copy.discardCloseConfirm)) { return; } discardDraftChanges(); } - setEditorOpen(false); + startEditorClose(); }; const openEditorForPack = (pack: StylePack) => { @@ -310,14 +362,17 @@ export function Style() { return; } } + if (editorCloseTimer.current !== null) { + window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = null; + } + setEditorClosing(false); focusPack(pack.id); setEditorOpen(true); }; useEffect(() => { if (!editorOpen) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -326,7 +381,6 @@ export function Style() { }; window.addEventListener('keydown', handleKeyDown); return () => { - document.body.style.overflow = previousOverflow; window.removeEventListener('keydown', handleKeyDown); }; }, [editorOpen, dirty, selectedPack, draft]); @@ -359,23 +413,19 @@ export function Style() { }); }; - const showSuccess = (message: string) => { - setNotice(message); - setError(null); - }; - const handleSave = async () => { if (!draft) return; setBusy('saving'); + showSaveStatus('saving', t('common.saving')); try { const saved = await saveStylePack({ ...draft, tags: draft.tags.filter(Boolean), }); - showSuccess(copy.saveSuccess); + showSaveStatus('saved', copy.saveSuccess, true); await loadPacks(saved.id); } catch (saveError) { - setError(copy.saveFailed(String(saveError))); + showSaveStatus('failed', copy.saveFailed(String(saveError))); } finally { setBusy(null); } @@ -385,23 +435,10 @@ export function Style() { setBusy('activating'); try { await setActiveStylePack(pack.id); - showSuccess(copy.activateSuccess(pack.name)); + showSaveStatus('saved', copy.activateSuccess(pack.name), true); await loadPacks(pack.id); } catch (activateError) { - setError(copy.activateFailed(String(activateError))); - } finally { - setBusy(null); - } - }; - - const handleToggleEnabled = async (pack: StylePack) => { - setBusy('toggling'); - try { - await setStylePackEnabled(pack.id, !pack.enabled); - showSuccess(pack.enabled ? copy.disableSuccess(pack.name) : copy.enableSuccess(pack.name)); - await loadPacks(pack.id); - } catch (toggleError) { - setError(copy.toggleFailed(String(toggleError))); + showSaveStatus('failed', copy.activateFailed(String(activateError))); } finally { setBusy(null); } @@ -412,10 +449,30 @@ export function Style() { setBusy('resetting'); try { await resetBuiltinStylePack(selectedPack.id); - showSuccess(copy.resetSuccess(selectedPack.name)); + showSaveStatus('saved', copy.resetSuccess(selectedPack.name), true); await loadPacks(selectedPack.id); } catch (resetError) { - setError(copy.resetFailed(String(resetError))); + showSaveStatus('failed', copy.resetFailed(String(resetError))); + } finally { + setBusy(null); + } + }; + + const handleDeleteImportedPack = async (pack: StylePack) => { + if (pack.kind !== 'imported') return; + if (!window.confirm(copy.deleteConfirm(pack.name))) { + return; + } + setBusy('deleting'); + try { + await deleteStylePack(pack.id); + showSaveStatus('saved', copy.deleteSuccess(pack.name), true); + if (editorOpen && selectedId === pack.id) { + startEditorClose(); + } + await loadPacks(); + } catch (deleteError) { + showSaveStatus('failed', copy.deleteFailed(String(deleteError))); } finally { setBusy(null); } @@ -423,17 +480,29 @@ export function Style() { const handleDeleteImported = async () => { if (!selectedPack || selectedPack.kind !== 'imported') return; - if (!window.confirm(copy.deleteConfirm(selectedPack.name))) { - return; - } - setBusy('deleting'); + await handleDeleteImportedPack(selectedPack); + }; + + const handleCreateFromTemplate = async () => { + setBusy('creating'); try { - await deleteStylePack(selectedPack.id); - showSuccess(copy.deleteSuccess(selectedPack.name)); - setEditorOpen(false); - await loadPacks(); - } catch (deleteError) { - setError(copy.deleteFailed(String(deleteError))); + const template: StylePack = { + ...NEW_PACK_TEMPLATE_BASE, + id: '', + }; + const created = await createStylePackFromTemplate(template); + showSaveStatus('saved', copy.createSuccess, true); + await loadPacks(created.id); + // Re-fetch list, then open the editor on the new pack + if (editorCloseTimer.current !== null) { + window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = null; + } + setEditorClosing(false); + setSelectedId(created.id); + setEditorOpen(true); + } catch (createError) { + showSaveStatus('failed', copy.createFailed(String(createError))); } finally { setBusy(null); } @@ -458,10 +527,10 @@ export function Style() { return; } const imported = await importStylePackFromZip(zipPath); - showSuccess(copy.importSuccess(imported.name)); + showSaveStatus('saved', copy.importSuccess(imported.name), true); await loadPacks(imported.id); } catch (importError) { - setError(copy.importFailed(String(importError))); + showSaveStatus('failed', copy.importFailed(String(importError))); } finally { setBusy(null); } @@ -470,8 +539,7 @@ export function Style() { const handleExportZip = async (pack = selectedPack) => { if (!pack) return; if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) { - setError(copy.exportDirtyFirst); - setNotice(null); + showSaveStatus('failed', copy.exportDirtyFirst); return; } setBusy('exporting'); @@ -492,9 +560,9 @@ export function Style() { return; } const savedPath = await exportStylePackToZip(pack.id, targetPath); - showSuccess(copy.exportSuccess(savedPath)); + showSaveStatus('saved', copy.exportSuccess(savedPath), true); } catch (exportError) { - setError(copy.exportFailed(String(exportError))); + showSaveStatus('failed', copy.exportFailed(String(exportError))); } finally { setBusy(null); } @@ -507,7 +575,7 @@ export function Style() { title={copy.title} desc={copy.desc} right={( -
+
void loadPacks(selectedId)} disabled={busy === 'loading'}> {t('common.refresh')} @@ -518,66 +586,51 @@ export function Style() { )} /> -
- -
- {copy.summaryBuiltin} -
-
{builtinCount}
-
{copy.summaryBuiltinHint}
-
- -
- {copy.summaryImported} -
-
{importedCount}
-
{copy.summaryImportedHint}
-
- -
- {copy.summaryEnabled} -
-
{enabledCount}
-
- {activePack ? copy.summaryCurrent(activePack.name) : copy.summaryCurrentEmpty} -
-
-
+ - {(notice || error) && ( -
- {error ?? notice} -
- )} - - -
-
-
-
{copy.listTitle}
-
{copy.listDesc}
-
-
+ +
+
+
+
+
{copy.listTitle}
+
{copy.listDesc}
+
+ {rawPack && ( + + )} +
{copy.listCount(packs.length)}
-
-
-
- {packs.map(pack => { - const selected = pack.id === selectedId; +
+
+ {bodyPacks.map(pack => { + const isBuiltin = pack.kind === 'builtin'; return (
-
{pack.name}
- - {pack.kind === 'builtin' ? copy.builtin : copy.imported} - -
- {pack.active ? ( - {copy.active} - ) : !pack.enabled ? ( - {copy.disabled} - ) : null} +
+ {pack.name}
+ + {isBuiltin ? copy.builtin : copy.imported} + + {pack.active && {copy.active}}
-
+ {isBuiltin ? (
- +
- openEditorForPack(pack)} + ) : ( +
+ + + )}
@@ -671,14 +733,6 @@ export function Style() { > {pack.active ? copy.active : copy.activate} - void handleToggleEnabled(pack)} - > - {pack.enabled ? copy.disable : copy.enable} - {copy.exportShort} + openEditorForPack(pack)} + > + {copy.edit} +
); })} +
@@ -704,10 +803,13 @@ export function Style() { style={{ position: 'fixed', inset: 0, - background: 'rgba(15,23,42,0.24)', - backdropFilter: 'blur(6px)', - WebkitBackdropFilter: 'blur(6px)', + background: 'rgba(15,17,22,0.32)', + backdropFilter: 'blur(8px) saturate(140%)', + WebkitBackdropFilter: 'blur(8px) saturate(140%)', zIndex: 40, + animation: editorClosing + ? 'ol-modal-backdrop-out 0.2s var(--ol-motion-soft) forwards' + : 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both', }} />
- +
@@ -779,13 +885,6 @@ export function Style() { void handleExportZip()} disabled={busy === 'exporting'}> {copy.exportZip} - void handleToggleEnabled(draft)} - > - {draft.enabled ? copy.disable : copy.enable} - -
@@ -997,9 +1095,24 @@ export function Style() { style={{ ...inputStyle, fontWeight: 600 }} placeholder={copy.exampleTitlePlaceholder(index + 1)} /> - removeExample(index)}> - {t('common.delete')} - +
diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 8cc79c58..a30aef3c 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -76,3 +76,35 @@ a { color: inherit; text-decoration: none; } filter: blur(0); } } + +@keyframes ol-modal-backdrop-in { + from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } + to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } +} + +@keyframes ol-modal-backdrop-out { + from { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } + to { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } +} + +@keyframes ol-modal-drawer-in { + from { + opacity: 0; + transform: translate3d(12px, 0, 0) scale(0.985); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes ol-modal-drawer-out { + from { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + to { + opacity: 0; + transform: translate3d(12px, 0, 0) scale(0.985); + } +}