Merge pull request #435 from Open-Less/feat/style-pack-ui-refresh
feat(style-pack): rework UI + 模板新建 + 清理 PR #429 遗留死代码
This commit is contained in:
18 files changed
+702
-273
No files matched your search
@@ -0,0 +1,299 @@
|
||||
# Style Pack Marketplace — 规划文档
|
||||
|
||||
**状态**:规划中(API 已预留 stub,未实装)
|
||||
**起草日期**:2026-05-14
|
||||
**owner**:待定
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把现在「ZIP 包本地导入 / 导出」的体验扩展成一个公开的风格包市场:
|
||||
|
||||
- 用户可以把自己调好的风格包**上传**到云端,附带名称、描述、作者署名、标签、效果示例
|
||||
- 其他用户可以**浏览 / 搜索 / 下载**别人的风格包,一键安装到本地
|
||||
- 后期支持**版本升级提醒**、**收藏 / 评分**等基础社交属性
|
||||
|
||||
非目标(v1 不做):
|
||||
- 付费 / 抽成
|
||||
- 风格包内嵌外部 prompt 注入 / 跨域 fetch(安全考虑,风格包始终是纯文本 prompt)
|
||||
- 多人协作编辑 / fork
|
||||
|
||||
## 2. 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────┐ HTTPS ┌─────────────────────┐
|
||||
│ OpenLess client │ ◄──────────────────► │ marketplace API │
|
||||
│ (Tauri 2) │ JSON over TLS │ (TBD: Cloudflare │
|
||||
│ │ │ Workers / D1 / │
|
||||
│ Rust IPC → │ │ R2 for blobs) │
|
||||
│ reqwest client │ │ │
|
||||
└──────────────────┘ └─────────────────────┘
|
||||
│ │
|
||||
│ local cache (~/Library/Application │
|
||||
│ Support/OpenLess/market_cache/) │
|
||||
▼ ▼
|
||||
StylePackStore Postgres / D1
|
||||
(existing local listings + R2 blobs
|
||||
persistence layer)
|
||||
```
|
||||
|
||||
**关键约束**:
|
||||
- 客户端只能上传 / 下载 ZIP **bundle**(不直接传 JSON),保持跟现有 ZIP import/export 同构
|
||||
- 服务端 ZIP 验证:解压后必须能反序列化成 `StylePack`、`prompt.chars().count() <= 50_000`、没有可执行附件
|
||||
- 风格包 ID 上传后由服务端分配(`{author_slug}-{name_slug}-{version}`),跟本地 ID 解耦
|
||||
- 客户端始终拿 ZIP 走现有 `import_style_pack_from_zip` 路径入库 —— 不另开一条「从市场直接写 Pack」的代码路径,避免双入口
|
||||
|
||||
## 3. HTTP API 规约
|
||||
|
||||
Base URL(待定):`https://api.openless.app/v1/marketplace/`
|
||||
|
||||
所有响应统一信封:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": <T> | null,
|
||||
"error": null | { "code": "ERR_XXX", "message": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 GET `/packs` — 列表 / 搜索
|
||||
|
||||
Query:
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `q` | string | `""` | 关键词(名称 / 描述 / 标签) |
|
||||
| `tag` | string | `""` | 单标签筛选 |
|
||||
| `sort` | `recent` \| `popular` \| `name` | `recent` | 排序 |
|
||||
| `cursor` | string | `null` | 分页游标 |
|
||||
| `limit` | int (1-100) | `20` | 每页条数 |
|
||||
|
||||
Response data:
|
||||
```typescript
|
||||
{
|
||||
packs: MarketPackListing[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
`MarketPackListing`:
|
||||
```typescript
|
||||
{
|
||||
id: string; // server-assigned, e.g. "alice-formal-v2.1"
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
version: string; // semver
|
||||
tags: string[];
|
||||
base_mode: "raw" | "light" | "structured" | "professional";
|
||||
recommended_model: string | null;
|
||||
compatible_app_version: string | null;
|
||||
downloads: number;
|
||||
rating_avg: number | null;
|
||||
rating_count: number;
|
||||
updated_at: string; // ISO8601
|
||||
zip_size_bytes: number;
|
||||
zip_sha256: string; // 客户端下载后校验
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 GET `/packs/{id}` — 详情
|
||||
|
||||
Response data:`MarketPackListing` + 额外字段:
|
||||
```typescript
|
||||
{
|
||||
...listing,
|
||||
examples: StylePackExample[]; // 解压 ZIP 前的预览
|
||||
changelog: string | null;
|
||||
homepage_url: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 GET `/packs/{id}/download` — 下载 ZIP
|
||||
|
||||
Response:`application/zip` 二进制流,带 `X-Pack-SHA256` header 用于校验。
|
||||
|
||||
服务端通过 redirect 直接指向 R2 / S3 预签 URL,避免代理流量。
|
||||
|
||||
### 3.4 POST `/packs` — 上传(需鉴权)
|
||||
|
||||
Headers:`Authorization: Bearer <api_key>`
|
||||
Body:`multipart/form-data` with field `pack=@xxx.zip`
|
||||
|
||||
Response data:`MarketPackListing`(含新分配 id)
|
||||
|
||||
错误码:
|
||||
- `ERR_INVALID_ZIP` — ZIP 解压失败 / 不是合法 StylePack JSON
|
||||
- `ERR_PROMPT_TOO_LARGE` — prompt 字数超 50k
|
||||
- `ERR_DUPLICATE_VERSION` — 同 author+name+version 已存在
|
||||
- `ERR_RATE_LIMITED` — 触发限频
|
||||
|
||||
### 3.5 DELETE `/packs/{id}` — 撤回(需鉴权 + 必须是上传者)
|
||||
|
||||
### 3.6 POST `/packs/{id}/rate` — 评分(需鉴权)
|
||||
|
||||
Body:`{ score: 1..5, comment?: string }`
|
||||
|
||||
## 4. IPC 契约(Rust ↔ TS)
|
||||
|
||||
在 `src-tauri/src/commands.rs` 新增以下 stub(暂返回 `Err("not implemented yet")`,等服务端落地后实装):
|
||||
|
||||
```rust
|
||||
// 列表 / 搜索
|
||||
#[tauri::command]
|
||||
pub async fn market_list_packs(
|
||||
query: Option<String>,
|
||||
tag: Option<String>,
|
||||
sort: Option<String>,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<MarketListResponse, String>;
|
||||
|
||||
// 详情
|
||||
#[tauri::command]
|
||||
pub async fn market_get_pack(id: String) -> Result<MarketPackDetail, String>;
|
||||
|
||||
// 下载 + 自动调用现有的 import_style_pack_from_zip 入库
|
||||
#[tauri::command]
|
||||
pub async fn market_download_pack(
|
||||
coord: CoordinatorState<'_>,
|
||||
app: AppHandle,
|
||||
id: String,
|
||||
) -> Result<StylePack, String>;
|
||||
|
||||
// 上传(dirty 字段 = 已编辑、未保存)
|
||||
#[tauri::command]
|
||||
pub async fn market_upload_pack(
|
||||
coord: CoordinatorState<'_>,
|
||||
pack_id: String,
|
||||
api_key: String,
|
||||
) -> Result<MarketPackListing, String>;
|
||||
|
||||
// 撤回
|
||||
#[tauri::command]
|
||||
pub async fn market_delete_pack(id: String, api_key: String) -> Result<(), String>;
|
||||
|
||||
// 评分
|
||||
#[tauri::command]
|
||||
pub async fn market_rate_pack(
|
||||
id: String,
|
||||
api_key: String,
|
||||
score: u8,
|
||||
comment: Option<String>,
|
||||
) -> Result<(), String>;
|
||||
```
|
||||
|
||||
DTO(在 `types.rs` 新增):
|
||||
```rust
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MarketPackListing {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub author: String,
|
||||
pub version: String,
|
||||
pub tags: Vec<String>,
|
||||
pub base_mode: PolishMode,
|
||||
pub recommended_model: Option<String>,
|
||||
pub compatible_app_version: Option<String>,
|
||||
pub downloads: u64,
|
||||
pub rating_avg: Option<f32>,
|
||||
pub rating_count: u32,
|
||||
pub updated_at: String,
|
||||
pub zip_size_bytes: u64,
|
||||
pub zip_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MarketPackDetail {
|
||||
#[serde(flatten)]
|
||||
pub listing: MarketPackListing,
|
||||
pub examples: Vec<StylePackExample>,
|
||||
pub changelog: Option<String>,
|
||||
pub homepage_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MarketListResponse {
|
||||
pub packs: Vec<MarketPackListing>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
TS wrappers(`src/lib/ipc.ts`):
|
||||
```typescript
|
||||
export interface MarketPackListing { /* same shape */ }
|
||||
export interface MarketPackDetail extends MarketPackListing { /* + examples, changelog, homepage_url */ }
|
||||
export interface MarketListResponse { packs: MarketPackListing[]; next_cursor: string | null; }
|
||||
|
||||
export function marketListPacks(opts: {
|
||||
query?: string; tag?: string; sort?: 'recent' | 'popular' | 'name';
|
||||
cursor?: string; limit?: number;
|
||||
}): Promise<MarketListResponse>;
|
||||
export function marketGetPack(id: string): Promise<MarketPackDetail>;
|
||||
export function marketDownloadPack(id: string): Promise<StylePack>;
|
||||
export function marketUploadPack(packId: string, apiKey: string): Promise<MarketPackListing>;
|
||||
export function marketDeletePack(id: string, apiKey: string): Promise<void>;
|
||||
export function marketRatePack(id: string, apiKey: string, score: number, comment?: string): Promise<void>;
|
||||
```
|
||||
|
||||
## 5. 鉴权模型
|
||||
|
||||
**v1 简化方案**:
|
||||
- 用户在设置页输入个人 API key(服务端发放)
|
||||
- API key 存到 OS Keychain,账户名 `com.openless.app.market_api_key`
|
||||
- 客户端在 Header 加 `Authorization: Bearer <key>`
|
||||
- 服务端校验 + 限频(每小时 60 次写、600 次读)
|
||||
|
||||
**v2 升级路径**(暂不做):
|
||||
- OAuth via GitHub / Google
|
||||
- 上传时自动签名 ZIP,下载端校验签名
|
||||
|
||||
## 6. 缓存与版本检查
|
||||
|
||||
本地缓存目录:`<app_data>/market_cache/`
|
||||
- `listings.json` — 上次拉的 listings(带 ETag)
|
||||
- `packs/{id}.zip` — 已下载的 ZIP(按需保留,30 天自动清理)
|
||||
|
||||
版本升级提示:
|
||||
- 启动时(带 dev-cap 24h 节流)调用 `/packs?ids=<已安装的 market_id...>` 拉对比
|
||||
- 本地包记录 `installed_market_id` 和 `installed_market_version` 字段,新建 `StylePack` 时填,本地从 ZIP 安装也填
|
||||
- 发现新版本 → 在 Style 页该包卡片角标显示 `New version: 2.3.0 →`
|
||||
|
||||
## 7. 客户端 UI 入口(v1 不做,先留位)
|
||||
|
||||
- Style 页头部加一个 tab:`本地 / 市场`
|
||||
- 市场页:搜索栏 + tag 过滤 + 卡片列表 + 详情抽屉
|
||||
- 上传:编辑某个本地包时,"导出 ZIP" 按钮旁边出现 "上传到市场"(需要先在设置里填 API key)
|
||||
|
||||
## 8. 安全 / 滥用对策
|
||||
|
||||
- ZIP 解压走 streaming,限制最大解压后大小 5 MB
|
||||
- prompt 字段过滤明显的 prompt injection / 越狱(关键词预扫描 + 异步内容审核)
|
||||
- 每用户每天上传上限 10 包,单包大小 ≤ 2 MB
|
||||
- 上传后挂 24h 公开延迟(防恶意刷榜)
|
||||
|
||||
## 9. 实装 TODO(按优先级)
|
||||
|
||||
- [ ] 服务端选型(CF Workers + D1 + R2 vs Supabase vs 自托管 FastAPI)
|
||||
- [ ] 服务端实装 + 部署环境(dev / staging / prod)
|
||||
- [ ] 客户端 `types.rs` 加 DTO
|
||||
- [ ] `commands.rs` 加 6 个 stub(**已完成**,返回 `not implemented yet`)
|
||||
- [ ] `lib/ipc.ts` 加 wrapper(**已完成**)
|
||||
- [ ] 实装 `market_download_pack`(先做单条路径打通:URL → 下载 → 走现有 import_style_pack_from_zip)
|
||||
- [ ] 加凭据存储(Keychain 复用现有 `CredentialsVault`)
|
||||
- [ ] UI:本地 / 市场 tab
|
||||
- [ ] UI:搜索 + 卡片
|
||||
- [ ] UI:详情面板
|
||||
- [ ] UI:上传流程
|
||||
- [ ] 升级提醒 badge
|
||||
- [ ] 缓存清理 + ETag
|
||||
|
||||
## 10. 决策 / 风险记录
|
||||
|
||||
| 项 | 决策 | Why |
|
||||
|---|---|---|
|
||||
| ZIP 而非 JSON 上传 | 用 ZIP | 跟现有 import/export 同构;prompt 长文 + examples 用 ZIP 包压缩 |
|
||||
| 服务端分配 ID | 是 | 防本地 ID 碰撞、用户重命名包不影响订阅 |
|
||||
| 上传立刻可见 vs 审核 | 24h 公开延迟 | 防刷榜 + 给审核留空间 |
|
||||
| API key vs OAuth | 先 API key | 简化 v1;登录态可 v2 升级 |
|
||||
| 客户端缓存策略 | listings ETag + 已下载 ZIP 30 天 | 平衡流量和体验 |
|
||||
| 国际化 / 跨境 | API 全英文 + 客户端 i18n | 服务端不存翻译,名称/描述支持任意 UTF-8 |
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2-1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2-1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3751,7 +3751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.3.1"
|
||||
version = "1.3.2-1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.3.1"
|
||||
version = "1.3.2-1"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1256,6 +1256,26 @@ pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result<Vec<StylePack>, S
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_style_pack_from_template(
|
||||
coord: CoordinatorState<'_>,
|
||||
app: AppHandle,
|
||||
template: StylePack,
|
||||
) -> Result<StylePack, String> {
|
||||
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<'_>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StylePack> {
|
||||
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<StylePack> {
|
||||
let mut packs = self.state.lock();
|
||||
let index = packs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2-1",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '口述をリアルタイムでテキストに転写。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '用于将口述实时转写为文本。',
|
||||
|
||||
@@ -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: '用於將口述實時轉寫爲文本。',
|
||||
|
||||
@@ -689,6 +689,22 @@ export function saveStylePack(stylePack: StylePack): Promise<StylePack> {
|
||||
});
|
||||
}
|
||||
|
||||
export function createStylePackFromTemplate(template: StylePack): Promise<StylePack> {
|
||||
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<StylePackRuntimeDiagnostics> {
|
||||
return invokeOrMock('preview_style_pack_runtime', { stylePack }, () => composeMockStylePackRuntimeDiagnostics(stylePack));
|
||||
}
|
||||
|
||||
@@ -1607,34 +1607,6 @@ function ProvidersSection() {
|
||||
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.styleSystemPromptTitle')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
|
||||
{t('settings.providers.styleSystemPromptDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 14,
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'linear-gradient(180deg, rgba(248,250,252,0.95), rgba(255,255,255,0.98))',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<Pill tone="blue">{t('settings.providers.styleSystemPromptMovedBadge')}</Pill>
|
||||
<Pill tone="outline">{t('settings.providers.styleSystemPromptTitle')}</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7, marginBottom: 10 }}>
|
||||
{t('settings.providers.styleSystemPromptMovedDesc')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.styleSystemPromptMovedHint')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.asrTitle')}</div>
|
||||
|
||||
@@ -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<StylePack, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
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<StylePack[]>([]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<StylePack | null>(null);
|
||||
const [busy, setBusy] = useState<BusyAction>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [saveState, setSaveState] = useState<SaveToastState>('idle');
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const statusTimer = useRef<number | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorClosing, setEditorClosing] = useState(false);
|
||||
const editorCloseTimer = useRef<number | null>(null);
|
||||
const [runtimePreview, setRuntimePreview] = useState<StylePackRuntimeDiagnostics | null>(null);
|
||||
const [runtimePreviewError, setRuntimePreviewError] = useState<string | null>(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={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
|
||||
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
|
||||
{t('common.refresh')}
|
||||
</Btn>
|
||||
@@ -518,66 +586,51 @@ export function Style() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
<Card padding={16} glassy>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
|
||||
{copy.summaryBuiltin}
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{builtinCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>{copy.summaryBuiltinHint}</div>
|
||||
</Card>
|
||||
<Card padding={16} glassy>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
|
||||
{copy.summaryImported}
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{importedCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>{copy.summaryImportedHint}</div>
|
||||
</Card>
|
||||
<Card padding={16} glassy>
|
||||
<div style={{ fontSize: 11, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>
|
||||
{copy.summaryEnabled}
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--ol-ink)' }}>{enabledCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4 }}>
|
||||
{activePack ? copy.summaryCurrent(activePack.name) : copy.summaryCurrentEmpty}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
{(notice || error) && (
|
||||
<div
|
||||
role={error ? 'alert' : 'status'}
|
||||
style={{
|
||||
marginBottom: 14,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
border: error ? '0.5px solid rgba(239,68,68,0.22)' : '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: error ? 'rgba(254,242,242,0.9)' : 'rgba(239,246,255,0.92)',
|
||||
color: error ? 'var(--ol-red, #b91c1c)' : 'var(--ol-blue)',
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{error ?? notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padding={0} style={{ overflow: 'hidden' }}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>{copy.listTitle}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4, maxWidth: 760 }}>{copy.listDesc}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>{copy.listTitle}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 4, maxWidth: 760 }}>{copy.listDesc}</div>
|
||||
</div>
|
||||
{rawPack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleActivate(rawPack)}
|
||||
disabled={rawPack.active || busy === 'activating'}
|
||||
title={rawPack.name}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
border: '0.5px solid',
|
||||
borderColor: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-line-strong)',
|
||||
background: rawPack.active ? 'var(--ol-blue-soft)' : 'transparent',
|
||||
color: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: rawPack.active ? 600 : 500,
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: rawPack.active ? 'default' : 'pointer',
|
||||
transition: 'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<span>{rawPack.name}</span>
|
||||
{rawPack.active && <span style={{ fontSize: 11, opacity: 0.85 }}>·{copy.active}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Pill tone="outline">{copy.listCount(packs.length)}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
{packs.map(pack => {
|
||||
const selected = pack.id === selectedId;
|
||||
<div className="ol-thinscroll" style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
{bodyPacks.map(pack => {
|
||||
const isBuiltin = pack.kind === 'builtin';
|
||||
return (
|
||||
<div
|
||||
key={pack.id}
|
||||
@@ -585,34 +638,31 @@ export function Style() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
textAlign: 'left',
|
||||
position: 'relative',
|
||||
border: '0.5px solid',
|
||||
borderColor: selected || pack.active ? 'var(--ol-blue)' : 'var(--ol-line)',
|
||||
borderColor: pack.active ? 'var(--ol-blue)' : 'var(--ol-line)',
|
||||
background: pack.active
|
||||
? 'linear-gradient(180deg, rgba(239,246,255,0.92), rgba(255,255,255,0.98))'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))',
|
||||
: isBuiltin
|
||||
? 'linear-gradient(180deg, rgba(248,250,252,0.92), rgba(241,245,249,0.85))'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))',
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
boxShadow: selected || pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none',
|
||||
boxShadow: pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none',
|
||||
cursor: 'default',
|
||||
opacity: pack.enabled ? 1 : 0.72,
|
||||
minHeight: 204,
|
||||
transition: 'border-color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{pack.name}</div>
|
||||
<Pill tone={pack.kind === 'builtin' ? 'outline' : 'blue'} size="sm">
|
||||
{pack.kind === 'builtin' ? copy.builtin : copy.imported}
|
||||
</Pill>
|
||||
<div style={{ minWidth: 42, minHeight: 24, display: 'flex', alignItems: 'center' }}>
|
||||
{pack.active ? (
|
||||
<Pill tone="dark" size="sm">{copy.active}</Pill>
|
||||
) : !pack.enabled ? (
|
||||
<Pill tone="default" size="sm">{copy.disabled}</Pill>
|
||||
) : null}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: isBuiltin && !pack.active ? 'var(--ol-ink-2)' : 'var(--ol-ink)' }}>
|
||||
{pack.name}
|
||||
</div>
|
||||
<Pill tone={isBuiltin ? 'outline' : 'blue'} size="sm">
|
||||
{isBuiltin ? copy.builtin : copy.imported}
|
||||
</Pill>
|
||||
{pack.active && <Pill tone="dark" size="sm">{copy.active}</Pill>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -630,29 +680,41 @@ export function Style() {
|
||||
{pack.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}>
|
||||
{isBuiltin ? (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 36, height: 36, borderRadius: 12,
|
||||
display: 'grid', placeItems: 'center',
|
||||
background: pack.active ? 'rgba(37,99,235,0.12)' : 'rgba(15,23,42,0.05)',
|
||||
color: pack.active ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name={pack.kind === 'builtin' ? 'sparkle' : 'archive'} size={16} />
|
||||
<Icon name="sparkle" size={16} />
|
||||
</div>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant={selected ? 'blue' : 'ghost'}
|
||||
icon="expand"
|
||||
onClick={() => openEditorForPack(pack)}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteImportedPack(pack)}
|
||||
disabled={busy === 'deleting'}
|
||||
aria-label={copy.deleteImported}
|
||||
title={copy.deleteImported}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 12,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
border: '0.5px solid rgba(239,68,68,0.32)',
|
||||
background: 'rgba(254,242,242,0.6)',
|
||||
color: 'var(--ol-red, #ef4444)',
|
||||
cursor: busy === 'deleting' ? 'wait' : 'pointer',
|
||||
opacity: busy === 'deleting' ? 0.55 : 1,
|
||||
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{copy.edit}
|
||||
</Btn>
|
||||
</div>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minHeight: 24, marginBottom: 12 }}>
|
||||
@@ -671,14 +733,6 @@ export function Style() {
|
||||
>
|
||||
{pack.active ? copy.active : copy.activate}
|
||||
</Btn>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy === 'toggling'}
|
||||
onClick={() => void handleToggleEnabled(pack)}
|
||||
>
|
||||
{pack.enabled ? copy.disable : copy.enable}
|
||||
</Btn>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -688,10 +742,55 @@ export function Style() {
|
||||
>
|
||||
{copy.exportShort}
|
||||
</Btn>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="expand"
|
||||
disabled={isBuiltin}
|
||||
onClick={() => openEditorForPack(pack)}
|
||||
>
|
||||
{copy.edit}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateFromTemplate()}
|
||||
disabled={busy === 'creating'}
|
||||
aria-label={copy.addPackTileTitle}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
textAlign: 'center',
|
||||
border: '0.5px dashed var(--ol-line-strong)',
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-3)',
|
||||
cursor: busy === 'creating' ? 'wait' : 'pointer',
|
||||
opacity: busy === 'creating' ? 0.55 : 1,
|
||||
minHeight: 204,
|
||||
transition: 'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 999,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(15,23,42,0.04)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={22} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{copy.addPackTileTitle}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', lineHeight: 1.55, maxWidth: 220 }}>{copy.addPackTileHint}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -721,6 +823,9 @@ export function Style() {
|
||||
bottom: 16,
|
||||
width: 'min(760px, calc(100vw - 32px))',
|
||||
zIndex: 41,
|
||||
animation: editorClosing
|
||||
? 'ol-modal-drawer-out 0.2s var(--ol-motion-soft) forwards'
|
||||
: 'ol-modal-drawer-in 0.28s var(--ol-motion-spring) both',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
@@ -744,18 +849,19 @@ export function Style() {
|
||||
onClick={closeEditor}
|
||||
aria-label={copy.closeEditor}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-3)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={15} />
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -779,13 +885,6 @@ export function Style() {
|
||||
<Btn variant="ghost" icon="archive" onClick={() => void handleExportZip()} disabled={busy === 'exporting'}>
|
||||
{copy.exportZip}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
disabled={busy === 'toggling'}
|
||||
onClick={() => void handleToggleEnabled(draft)}
|
||||
>
|
||||
{draft.enabled ? copy.disable : copy.enable}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant={draft.active ? 'soft' : 'blue'}
|
||||
icon="check"
|
||||
@@ -960,7 +1059,6 @@ export function Style() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
|
||||
<MetaItem label={copy.metaSource} value={draft.kind === 'builtin' ? copy.builtin : copy.imported} />
|
||||
<MetaItem label={copy.metaBaseMode} value={t(`style.modes.${draft.baseMode}.name`)} />
|
||||
<MetaItem label={copy.metaStatus} value={draft.enabled ? copy.enabled : copy.disabled} />
|
||||
<MetaItem label={copy.metaUpdatedAt} value={draft.updatedAt || '—'} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -997,9 +1095,24 @@ export function Style() {
|
||||
style={{ ...inputStyle, fontWeight: 600 }}
|
||||
placeholder={copy.exampleTitlePlaceholder(index + 1)}
|
||||
/>
|
||||
<Btn variant="ghost" size="sm" icon="trash" onClick={() => removeExample(index)}>
|
||||
{t('common.delete')}
|
||||
</Btn>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeExample(index)}
|
||||
aria-label={t('common.delete')}
|
||||
style={{
|
||||
width: 32, height: 32,
|
||||
flexShrink: 0,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-2)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user