paseo-kanban 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/README.zh-CN.md +111 -0
- package/client/i18n.tsx +328 -0
- package/client/kanban-board.tsx +545 -0
- package/client/kanban-card.tsx +326 -0
- package/client/kanban-drag.ts +1393 -0
- package/client/kanban-lane.tsx +240 -0
- package/client/kanban-overlay.tsx +186 -0
- package/client/kanban-session.ts +696 -0
- package/client/lane-modal.tsx +267 -0
- package/client/task-modal.tsx +498 -0
- package/client/use-projects.ts +112 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +7 -0
- package/package.json +41 -0
- package/paseo-plugin.json +6 -0
- package/shared/kanban.ts +465 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 breathi3552
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Paseo Kanban
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A lightweight, responsive Kanban board plugin for [Paseo](https://paseo.sh) workspaces. Organize tasks across customizable swimlanes, break them down into subtasks, associate cards with native Paseo projects, and manage workflow status with intuitive drag-and-drop.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Flexible Swimlanes**: Default lanes (`To Plan`, `In Progress`, `Done`) with full support for adding, renaming, and deleting custom lanes.
|
|
12
|
+
- **Rich Task Cards**:
|
|
13
|
+
- Task title and multi-line markdown description.
|
|
14
|
+
- Subtasks checklist with completion toggles and live progress tracking.
|
|
15
|
+
- Native Paseo project association.
|
|
16
|
+
- **Project Filtering**: Instantly filter board cards by linked Paseo project to keep context focused.
|
|
17
|
+
- **Smooth Drag-and-Drop**: Real-time hit-testing for reordering cards within lanes and moving cards across lanes.
|
|
18
|
+
- **Versioned State Persistence**: Persists board data through Paseo's settings API with optimistic concurrency control (CAS) to prevent race conditions and accidental overwrites.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- **Paseo**: `>= 0.8.0`
|
|
25
|
+
- **Node.js**: `>= 20.0.0` (recommended for development)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation & Usage
|
|
30
|
+
|
|
31
|
+
### Quick Install (Recommended)
|
|
32
|
+
|
|
33
|
+
No manual build or git cloning needed. You can install directly via npm or GitHub:
|
|
34
|
+
|
|
35
|
+
**Via NPM (Release package):**
|
|
36
|
+
```bash
|
|
37
|
+
paseo plugin install npm:paseo-kanban
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Via GitHub:**
|
|
41
|
+
```bash
|
|
42
|
+
paseo plugin add breathi3552/paseo-kanban
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To explicitly track the latest updates on the `main` branch:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
paseo plugin add breathi3552/paseo-kanban --ref main
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Verify that the plugin is running:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
paseo plugin ls
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Once active, open Paseo. The **Kanban** icon (`PanelsTopLeft`) will appear in the sidebar. Click it to launch the board.
|
|
58
|
+
|
|
59
|
+
### Local Development
|
|
60
|
+
|
|
61
|
+
To contribute or debug locally:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 1. Clone repository and install dependencies
|
|
65
|
+
git clone https://github.com/breathi3552/paseo-kanban.git
|
|
66
|
+
cd paseo-kanban
|
|
67
|
+
npm install
|
|
68
|
+
|
|
69
|
+
# 2. Link this directory into your local Paseo daemon
|
|
70
|
+
paseo plugin link .
|
|
71
|
+
```
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Development & Testing
|
|
75
|
+
|
|
76
|
+
### Install Dependencies
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm install
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Run Tests
|
|
83
|
+
|
|
84
|
+
Runs unit tests for board domain logic, subtask mutations, and drag hit-testing:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm test
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Type Checking
|
|
91
|
+
|
|
92
|
+
Validates TypeScript types across client and server entry points:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm run typecheck
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Architecture & Design
|
|
101
|
+
|
|
102
|
+
The complete architectural breakdown and interactive visual diagram are archived in the repository:
|
|
103
|
+
|
|
104
|
+
- **Interactive Architecture Viewer**: Open [`docs/architecture/paseo-kanban.architecture.html`](./docs/architecture/paseo-kanban.architecture.html) in your browser.
|
|
105
|
+
- **Architecture Schema & Model**: [`docs/architecture/paseo-kanban.architecture.json`](./docs/architecture/paseo-kanban.architecture.json).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Paseo 看板插件 (Paseo Kanban)
|
|
2
|
+
|
|
3
|
+
[English](./README.md)
|
|
4
|
+
|
|
5
|
+
专为 [Paseo](https://paseo.sh) 工作区打造的轻量响应式看板插件。支持自定义泳道分类、任务子步骤细化、原生 Paseo 项目关联筛选,以及跨泳道流畅拖拽管理。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 核心功能
|
|
10
|
+
|
|
11
|
+
- **灵活的泳道管理**:开箱即用三列基础泳道(`待规划`、`执行中`、`已完成`),支持自由添加、重命名与删除自定义泳道。
|
|
12
|
+
- **丰富的任务卡片**:
|
|
13
|
+
- 任务标题与多行描述编辑。
|
|
14
|
+
- 子步骤清单支持独立增删改、完成状态勾选及实时进度展示。
|
|
15
|
+
- 支持关联本地已有的 Paseo 项目。
|
|
16
|
+
- **项目维度筛选**:一键按关联的 Paseo 项目过滤看板卡片,聚焦当前目标。
|
|
17
|
+
- **流畅拖拽交互**:精准的拖拽命中测试(Hit-Testing),支持同泳道排序与跨泳道状态流转。
|
|
18
|
+
- **版本化数据持久化**:基于 Paseo 原生 settings API 存储,内置乐观并发版本控制(CAS),杜绝多会话覆写与状态不一致。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 环境要求
|
|
23
|
+
|
|
24
|
+
- **Paseo**:`>= 0.8.0`
|
|
25
|
+
- **Node.js**:`>= 20.0.0`(开发环境推荐)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 安装使用
|
|
30
|
+
|
|
31
|
+
### 快速安装(推荐)
|
|
32
|
+
|
|
33
|
+
无需克隆源码或手动编译,推荐直接使用 Paseo CLI 安装:
|
|
34
|
+
|
|
35
|
+
**方式一:通过 NPM 官方包安装(推荐)**
|
|
36
|
+
```bash
|
|
37
|
+
paseo plugin install npm:paseo-kanban
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**方式二:直接通过 GitHub 仓库安装**
|
|
41
|
+
```bash
|
|
42
|
+
paseo plugin add breathi3552/paseo-kanban
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
若需显式跟踪 `main` 分支最新更新:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
paseo plugin add breathi3552/paseo-kanban --ref main
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
安装后可通过以下命令检查运行状态:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
paseo plugin ls
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
状态显示为 `running` 后打开 Paseo,侧边栏将出现 **看板** 入口(图标:`PanelsTopLeft`),点击即可进入看板。
|
|
58
|
+
|
|
59
|
+
### 本地开发与调试
|
|
60
|
+
|
|
61
|
+
如需进行本地源码调试或二次开发:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 1. 克隆代码仓库并安装依赖
|
|
65
|
+
git clone https://github.com/breathi3552/paseo-kanban.git
|
|
66
|
+
cd paseo-kanban
|
|
67
|
+
npm install
|
|
68
|
+
|
|
69
|
+
# 2. 将当前开发目录软链接至本地 Paseo 守护进程
|
|
70
|
+
paseo plugin link .
|
|
71
|
+
```
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 开发与测试
|
|
75
|
+
|
|
76
|
+
### 安装依赖
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm install
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 运行单元测试
|
|
83
|
+
|
|
84
|
+
执行看板状态模型、并发校验、子步骤更新以及拖拽命中测试的自动化用例:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm test
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 类型检查
|
|
91
|
+
|
|
92
|
+
校验前端组件与服务端扩展的 TypeScript 类型完整性:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm run typecheck
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 架构设计
|
|
101
|
+
|
|
102
|
+
完整的插件架构图景与交互式全景模型已归档于仓库:
|
|
103
|
+
|
|
104
|
+
- **交互式架构全景图**:直接使用浏览器打开 [`docs/architecture/paseo-kanban.architecture.html`](./docs/architecture/paseo-kanban.architecture.html) 查看。
|
|
105
|
+
- **架构定义元数据**:[`docs/architecture/paseo-kanban.architecture.json`](./docs/architecture/paseo-kanban.architecture.json)。
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 开源协议
|
|
110
|
+
|
|
111
|
+
MIT
|
package/client/i18n.tsx
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useState, useEffect, useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
export type SupportedLanguage = "zh" | "en";
|
|
5
|
+
|
|
6
|
+
export type TranslationParams = Record<string, string | number | boolean | null | undefined>;
|
|
7
|
+
|
|
8
|
+
export const translations = {
|
|
9
|
+
zh: {
|
|
10
|
+
"kanban.sidebarTitle": "看板",
|
|
11
|
+
"kanban.title": "任务看板",
|
|
12
|
+
"kanban.loading": "正在加载看板数据...",
|
|
13
|
+
"kanban.stats": "{tasks} 任务 · {lanes} 泳道",
|
|
14
|
+
"kanban.addLane": "+ 新增泳道",
|
|
15
|
+
"kanban.newTask": "+ 新建任务",
|
|
16
|
+
"kanban.refresh": "刷新最新数据",
|
|
17
|
+
"kanban.loadError": "加载看板配置失败:{error}",
|
|
18
|
+
"kanban.formatError": "看板配置格式异常:{error}",
|
|
19
|
+
"kanban.formatErrorHint": "数据未被悄悄覆盖。若需使用默认结构可重置,或重试加载。",
|
|
20
|
+
"kanban.retryLoad": "重试加载",
|
|
21
|
+
"kanban.resetDefault": "重置为默认结构",
|
|
22
|
+
"kanban.saveFailed": "保存失败:{error}",
|
|
23
|
+
"kanban.conflictError": "保存冲突或错误:{error}",
|
|
24
|
+
"kanban.filterLabel": "项目筛选:",
|
|
25
|
+
"kanban.filterAll": "全部",
|
|
26
|
+
"kanban.filterUnassigned": "未关联项目",
|
|
27
|
+
"kanban.projectsError": "(项目列表更新受限)",
|
|
28
|
+
"lane.add": "+ 添加",
|
|
29
|
+
"lane.manage": "管理",
|
|
30
|
+
"lane.emptyHint": "暂无卡片,可点击右上角添加或拖动卡片至此",
|
|
31
|
+
"card.dragHandle": "拖动手柄",
|
|
32
|
+
"card.subtasksCount": "子步骤 {completed}/{total}",
|
|
33
|
+
"laneModal.titleEdit": "管理泳道",
|
|
34
|
+
"laneModal.titleCreate": "新增泳道",
|
|
35
|
+
"laneModal.nameLabel": "泳道名称 *",
|
|
36
|
+
"laneModal.namePlaceholder": "例如:待测试、发布中...",
|
|
37
|
+
"laneModal.cannotDeleteLast": "看板必须至少保留一条泳道,无法删除此泳道。",
|
|
38
|
+
"laneModal.cannotDeleteHasTasks": "此泳道当前包含 {count} 个任务,需清空或移走任务后方可删除。",
|
|
39
|
+
"laneModal.deleteLane": "删除此泳道",
|
|
40
|
+
"laneModal.confirmDeletePrompt": "确认删除?",
|
|
41
|
+
"laneModal.confirmDelete": "确认删除",
|
|
42
|
+
"laneModal.cancel": "取消",
|
|
43
|
+
"laneModal.saving": "保存中...",
|
|
44
|
+
"laneModal.saveChanges": "保存修改",
|
|
45
|
+
"laneModal.submitCreate": "添加泳道",
|
|
46
|
+
"taskModal.titleEdit": "编辑任务",
|
|
47
|
+
"taskModal.titleCreate": "创建任务",
|
|
48
|
+
"taskModal.titleLabel": "任务标题 *",
|
|
49
|
+
"taskModal.titlePlaceholder": "输入任务标题",
|
|
50
|
+
"taskModal.laneLabel": "所属泳道",
|
|
51
|
+
"taskModal.projectLabel": "关联项目",
|
|
52
|
+
"taskModal.noProject": "未关联项目",
|
|
53
|
+
"taskModal.descLabel": "描述",
|
|
54
|
+
"taskModal.descPlaceholder": "可选的任务补充描述...",
|
|
55
|
+
"taskModal.subtasksLabel": "子步骤 ({completed}/{total})",
|
|
56
|
+
"taskModal.subtaskPlaceholder": "子步骤内容",
|
|
57
|
+
"taskModal.newSubtaskPlaceholder": "添加新的子步骤...",
|
|
58
|
+
"taskModal.addStep": "添加步骤",
|
|
59
|
+
"taskModal.deleteTask": "删除此任务",
|
|
60
|
+
"taskModal.confirmDeletePrompt": "确定删除?",
|
|
61
|
+
"taskModal.confirmDelete": "确认删除",
|
|
62
|
+
"taskModal.cancel": "取消",
|
|
63
|
+
"taskModal.saving": "保存中...",
|
|
64
|
+
"taskModal.saveChanges": "保存修改",
|
|
65
|
+
"taskModal.submitCreate": "创建",
|
|
66
|
+
},
|
|
67
|
+
en: {
|
|
68
|
+
"kanban.sidebarTitle": "Kanban",
|
|
69
|
+
"kanban.title": "Task Kanban",
|
|
70
|
+
"kanban.loading": "Loading kanban board data...",
|
|
71
|
+
"kanban.stats": "{tasks} tasks · {lanes} lanes",
|
|
72
|
+
"kanban.addLane": "+ Add Lane",
|
|
73
|
+
"kanban.newTask": "+ New Task",
|
|
74
|
+
"kanban.refresh": "Refresh latest data",
|
|
75
|
+
"kanban.loadError": "Failed to load kanban settings: {error}",
|
|
76
|
+
"kanban.formatError": "Kanban settings format error: {error}",
|
|
77
|
+
"kanban.formatErrorHint": "Data was not silently overwritten. Reset to default structure if needed, or retry loading.",
|
|
78
|
+
"kanban.retryLoad": "Retry Loading",
|
|
79
|
+
"kanban.resetDefault": "Reset to Default",
|
|
80
|
+
"kanban.saveFailed": "Save failed: {error}",
|
|
81
|
+
"kanban.conflictError": "Save conflict or error: {error}",
|
|
82
|
+
"kanban.filterLabel": "Project Filter:",
|
|
83
|
+
"kanban.filterAll": "All",
|
|
84
|
+
"kanban.filterUnassigned": "No Project",
|
|
85
|
+
"kanban.projectsError": "(Project list update restricted)",
|
|
86
|
+
"lane.add": "+ Add",
|
|
87
|
+
"lane.manage": "Manage",
|
|
88
|
+
"lane.emptyHint": "No cards yet. Click '+' in the top right or drag cards here.",
|
|
89
|
+
"card.dragHandle": "Drag handle",
|
|
90
|
+
"card.subtasksCount": "Subtasks {completed}/{total}",
|
|
91
|
+
"laneModal.titleEdit": "Manage Lane",
|
|
92
|
+
"laneModal.titleCreate": "Add Lane",
|
|
93
|
+
"laneModal.nameLabel": "Lane Name *",
|
|
94
|
+
"laneModal.namePlaceholder": "e.g., Testing, In Review...",
|
|
95
|
+
"laneModal.cannotDeleteLast": "A kanban board must retain at least one lane. Cannot delete this lane.",
|
|
96
|
+
"laneModal.cannotDeleteHasTasks": "This lane currently contains {count} tasks. Clear or move tasks before deleting.",
|
|
97
|
+
"laneModal.deleteLane": "Delete Lane",
|
|
98
|
+
"laneModal.confirmDeletePrompt": "Confirm delete?",
|
|
99
|
+
"laneModal.confirmDelete": "Confirm Delete",
|
|
100
|
+
"laneModal.cancel": "Cancel",
|
|
101
|
+
"laneModal.saving": "Saving...",
|
|
102
|
+
"laneModal.saveChanges": "Save Changes",
|
|
103
|
+
"laneModal.submitCreate": "Add Lane",
|
|
104
|
+
"taskModal.titleEdit": "Edit Task",
|
|
105
|
+
"taskModal.titleCreate": "Create Task",
|
|
106
|
+
"taskModal.titleLabel": "Task Title *",
|
|
107
|
+
"taskModal.titlePlaceholder": "Enter task title",
|
|
108
|
+
"taskModal.laneLabel": "Lane",
|
|
109
|
+
"taskModal.projectLabel": "Associated Project",
|
|
110
|
+
"taskModal.noProject": "No Project",
|
|
111
|
+
"taskModal.descLabel": "Description",
|
|
112
|
+
"taskModal.descPlaceholder": "Optional task description...",
|
|
113
|
+
"taskModal.subtasksLabel": "Subtasks ({completed}/{total})",
|
|
114
|
+
"taskModal.subtaskPlaceholder": "Subtask content",
|
|
115
|
+
"taskModal.newSubtaskPlaceholder": "Add a new subtask...",
|
|
116
|
+
"taskModal.addStep": "Add Step",
|
|
117
|
+
"taskModal.deleteTask": "Delete Task",
|
|
118
|
+
"taskModal.confirmDeletePrompt": "Confirm delete?",
|
|
119
|
+
"taskModal.confirmDelete": "Confirm Delete",
|
|
120
|
+
"taskModal.cancel": "Cancel",
|
|
121
|
+
"taskModal.saving": "Saving...",
|
|
122
|
+
"taskModal.saveChanges": "Save Changes",
|
|
123
|
+
"taskModal.submitCreate": "Create",
|
|
124
|
+
},
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
export type TranslationKey = keyof typeof translations.zh;
|
|
128
|
+
|
|
129
|
+
export function normalizeLanguage(locale?: string | null): SupportedLanguage {
|
|
130
|
+
if (!locale) return "en";
|
|
131
|
+
return locale.trim().toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let languageOverride: SupportedLanguage | null = null;
|
|
135
|
+
|
|
136
|
+
export function setLanguageOverride(lang: SupportedLanguage | null): void {
|
|
137
|
+
languageOverride = lang;
|
|
138
|
+
notifyLanguageChange();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getLanguageOverride(): SupportedLanguage | null {
|
|
142
|
+
return languageOverride;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DetectLanguageOptions {
|
|
146
|
+
locale?: string;
|
|
147
|
+
language?: string;
|
|
148
|
+
host?: {
|
|
149
|
+
id?: string;
|
|
150
|
+
label?: string;
|
|
151
|
+
locale?: string;
|
|
152
|
+
language?: string;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface EnvironmentGlobals {
|
|
157
|
+
localStorage?: {
|
|
158
|
+
getItem(key: string): string | null;
|
|
159
|
+
};
|
|
160
|
+
document?: {
|
|
161
|
+
documentElement?: {
|
|
162
|
+
lang?: string;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
navigator?: {
|
|
166
|
+
language?: string;
|
|
167
|
+
};
|
|
168
|
+
window?: {
|
|
169
|
+
addEventListener(event: string, listener: (event: { key?: string }) => void): void;
|
|
170
|
+
};
|
|
171
|
+
i18n?: {
|
|
172
|
+
language?: string;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const env = globalThis as typeof globalThis & EnvironmentGlobals;
|
|
177
|
+
|
|
178
|
+
export function detectPaseoLanguage(hostProps?: DetectLanguageOptions): SupportedLanguage {
|
|
179
|
+
if (languageOverride) {
|
|
180
|
+
return languageOverride;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const propLocale =
|
|
184
|
+
hostProps?.locale ??
|
|
185
|
+
hostProps?.language ??
|
|
186
|
+
hostProps?.host?.locale ??
|
|
187
|
+
hostProps?.host?.language;
|
|
188
|
+
if (propLocale && propLocale !== "system") {
|
|
189
|
+
return normalizeLanguage(propLocale);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (env.localStorage) {
|
|
193
|
+
try {
|
|
194
|
+
const raw = env.localStorage.getItem("@paseo:app-settings");
|
|
195
|
+
if (raw) {
|
|
196
|
+
const parsed = JSON.parse(raw);
|
|
197
|
+
if (parsed?.language && parsed.language !== "system") {
|
|
198
|
+
return normalizeLanguage(parsed.language);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const docLang = env.document?.documentElement?.lang;
|
|
205
|
+
if (docLang) {
|
|
206
|
+
return normalizeLanguage(docLang);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const i18nLang = env.i18n?.language;
|
|
210
|
+
if (i18nLang) {
|
|
211
|
+
return normalizeLanguage(i18nLang);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const intlLocale =
|
|
215
|
+
typeof Intl?.DateTimeFormat === "function"
|
|
216
|
+
? Intl.DateTimeFormat().resolvedOptions().locale
|
|
217
|
+
: undefined;
|
|
218
|
+
if (intlLocale) {
|
|
219
|
+
return normalizeLanguage(intlLocale);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const navLocale = env.navigator?.language;
|
|
223
|
+
if (navLocale) {
|
|
224
|
+
return normalizeLanguage(navLocale);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return "en";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let currentLanguage: SupportedLanguage = detectPaseoLanguage();
|
|
231
|
+
const listeners = new Set<(lang: SupportedLanguage) => void>();
|
|
232
|
+
let globalListenersInitialized = false;
|
|
233
|
+
|
|
234
|
+
function notifyLanguageChange() {
|
|
235
|
+
const next = detectPaseoLanguage();
|
|
236
|
+
if (next !== currentLanguage) {
|
|
237
|
+
currentLanguage = next;
|
|
238
|
+
listeners.forEach((fn) => fn(currentLanguage));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ensureGlobalListeners() {
|
|
243
|
+
if (globalListenersInitialized) return;
|
|
244
|
+
globalListenersInitialized = true;
|
|
245
|
+
|
|
246
|
+
env.window?.addEventListener("storage", (e) => {
|
|
247
|
+
if (e.key === "@paseo:app-settings") {
|
|
248
|
+
notifyLanguageChange();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
env.window?.addEventListener("focus", () => {
|
|
253
|
+
notifyLanguageChange();
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function getLanguage(hostProps?: DetectLanguageOptions): SupportedLanguage {
|
|
258
|
+
if (hostProps) {
|
|
259
|
+
return detectPaseoLanguage(hostProps);
|
|
260
|
+
}
|
|
261
|
+
return currentLanguage;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function subscribeLanguage(listener: (lang: SupportedLanguage) => void): () => void {
|
|
265
|
+
listeners.add(listener);
|
|
266
|
+
ensureGlobalListeners();
|
|
267
|
+
return () => {
|
|
268
|
+
listeners.delete(listener);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function formatString(template: string, params?: TranslationParams): string {
|
|
273
|
+
if (!params) return template;
|
|
274
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
275
|
+
return params[key] != null ? String(params[key]) : match;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function t(
|
|
280
|
+
key: TranslationKey,
|
|
281
|
+
langOrParams?: SupportedLanguage | TranslationParams,
|
|
282
|
+
params?: TranslationParams
|
|
283
|
+
): string {
|
|
284
|
+
let lang: SupportedLanguage;
|
|
285
|
+
let finalParams: TranslationParams | undefined;
|
|
286
|
+
|
|
287
|
+
if (typeof langOrParams === "string") {
|
|
288
|
+
lang = langOrParams;
|
|
289
|
+
finalParams = params;
|
|
290
|
+
} else {
|
|
291
|
+
lang = currentLanguage;
|
|
292
|
+
finalParams = langOrParams;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const dict = translations[lang] ?? translations.en;
|
|
296
|
+
const raw = dict[key] ?? translations.en[key] ?? key;
|
|
297
|
+
return formatString(raw, finalParams);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface I18nContextValue {
|
|
301
|
+
language: SupportedLanguage;
|
|
302
|
+
t: (key: TranslationKey, params?: TranslationParams) => string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function useI18n(hostProps?: DetectLanguageOptions): I18nContextValue {
|
|
306
|
+
const [lang, setLang] = useState<SupportedLanguage>(() => detectPaseoLanguage(hostProps));
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
ensureGlobalListeners();
|
|
310
|
+
const immediate = detectPaseoLanguage(hostProps);
|
|
311
|
+
if (immediate !== lang) {
|
|
312
|
+
setLang(immediate);
|
|
313
|
+
}
|
|
314
|
+
return subscribeLanguage((newLang) => {
|
|
315
|
+
setLang(newLang);
|
|
316
|
+
});
|
|
317
|
+
}, [hostProps?.host?.language, hostProps?.locale, hostProps?.language]);
|
|
318
|
+
|
|
319
|
+
const tBound = useMemo(
|
|
320
|
+
() => (key: TranslationKey, params?: TranslationParams) => t(key, lang, params),
|
|
321
|
+
[lang]
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
language: lang,
|
|
326
|
+
t: tBound,
|
|
327
|
+
};
|
|
328
|
+
}
|