dsh-plugin 0.1.1 → 1.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/README.en.md +56 -28
- package/README.md +56 -28
- package/client/client.js +2282 -230
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +432 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +66 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/npm-resolve.js +67 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +457 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +69 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +24 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/npm-resolve.d.ts +6 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +82 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +243 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +342 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +368 -0
- package/src/client/hooks/useCatalog.ts +202 -0
- package/src/client/hooks/useTaskQueue.ts +502 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +240 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +165 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +188 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +1006 -0
- package/src/client/styles/tokens.module.css +91 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +432 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +69 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +84 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/npm-resolve.ts +67 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +492 -0
- package/src/client/Section.module.css +0 -589
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 待重启列表的内存态管理(宿主进程一重启自然清空)。 */
|
|
2
|
+
import type { PendingRestart } from './install-types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 登记一条待重启记录(同一目标去重)。
|
|
5
|
+
* - 卸载:目标刚装完未重启(已有待重启记录)→ 宿主从未加载过它,卸完磁盘已干净、
|
|
6
|
+
* loader 无残留,无需再提醒重启,直接移除该条;否则(此前已被宿主加载过)登记「卸载后待重启清理」。
|
|
7
|
+
* - 安装:已有记录则刷新为安装语义(如卸载后未重启又重装),否则新登记。
|
|
8
|
+
*/
|
|
9
|
+
export declare function addPendingRestart(target: string, kind: 'install' | 'uninstall'): void;
|
|
10
|
+
/** 当前待重启列表快照(副本,消费方不会污染内部数组)。 */
|
|
11
|
+
export declare function listPendingRestarts(): PendingRestart[];
|
|
12
|
+
/** 清除某目标的待重启记录(卸载已即时生效、无需重启时调用)。 */
|
|
13
|
+
export declare function clearPendingRestart(target: string): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Resolve the active profile from the booted CLI args, falling back to `web`. */
|
|
2
|
+
export declare function readProfileArg(fallback?: string): string;
|
|
3
|
+
/** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
|
|
4
|
+
export declare function profileDirectory(profile: string): string;
|
|
5
|
+
/** Build a safe `github:<owner>/<repo>` target, or null when the repo is unsafe. */
|
|
6
|
+
export declare function githubTarget(repo: string): string | null;
|
|
7
|
+
/** Validate an npm package name (uninstall target grammar). */
|
|
8
|
+
export declare function validPackageName(name: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The exact `allowBuilds` key pnpm printed in its
|
|
11
|
+
* `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` hint. For `github:` installs pnpm
|
|
12
|
+
* matches against `name@<resolved fetch URL>` — the URL it actually fetches,
|
|
13
|
+
* e.g. `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>` — not the
|
|
14
|
+
* `git+ssh://`/branch spec. A bare name or a `git+` spec does not match, so
|
|
15
|
+
* the only reliable key is the one pnpm prints verbatim. Null when absent.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseAllowBuildsKey(output: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Add one `allowBuilds` entry to the profile's pnpm-workspace.yaml, merging
|
|
20
|
+
* with existing entries and leaving the rest of the yaml intact. Broken
|
|
21
|
+
* placeholder entries (pnpm's failed-install bug writes a literal
|
|
22
|
+
* `set this to true or false` value) are dropped on rewrite.
|
|
23
|
+
*/
|
|
24
|
+
export declare function addAllowBuildsKey(profile: string, key: string): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure progress-estimation helpers shared by the install runner.
|
|
3
|
+
*
|
|
4
|
+
* The dsh CLI wraps pnpm, which refreshes its `Progress: …` line in place
|
|
5
|
+
* with carriage returns, so raw output arrives as a stream of partial
|
|
6
|
+
* fragments. These helpers normalize one fragment into a clean terminal
|
|
7
|
+
* line and map it onto a 0-100 estimate; keeping them free of I/O makes the
|
|
8
|
+
* estimation logic directly unit-testable (see tests/progress.test.ts).
|
|
9
|
+
*/
|
|
10
|
+
/** Strip the `[out]` / `[err]` source prefix and ANSI escape sequences. */
|
|
11
|
+
export declare function cleanLine(raw: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Estimate 0-100 progress from one CLI output line. pnpm emits
|
|
14
|
+
* `Progress: resolved N, reused X, downloaded Y, added Z` during the fetch
|
|
15
|
+
* phase; resolution/install phase lines bump the estimate towards done.
|
|
16
|
+
*/
|
|
17
|
+
export declare function estimateProgress(line: string): number;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { InstallResult, InstallTask } from './install-types.ts';
|
|
2
|
+
import type { LoaderHandle } from './loader.ts';
|
|
3
|
+
/** Snapshot of a task (keeps the live object untouched by consumers). */
|
|
4
|
+
export declare function getTask(id: number): InstallTask | undefined;
|
|
5
|
+
export interface ActiveTaskInfo {
|
|
6
|
+
id: number;
|
|
7
|
+
target: string;
|
|
8
|
+
action: 'add' | 'remove';
|
|
9
|
+
status: 'pending' | 'running';
|
|
10
|
+
progress: number;
|
|
11
|
+
lines: string[];
|
|
12
|
+
/** 尝试过的安装方式(npm 反查 + 实际执行命令):失败时前端 issue 预填需要 */
|
|
13
|
+
attempts: string[];
|
|
14
|
+
}
|
|
15
|
+
/** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
|
|
16
|
+
export declare function activeTask(): ActiveTaskInfo[];
|
|
17
|
+
/**
|
|
18
|
+
* True while a mutation child process is still alive. The concurrent slot is
|
|
19
|
+
* keyed off the live child set rather than task.status: cancelling a running
|
|
20
|
+
* task flips its status immediately, but the child may still be shutting down
|
|
21
|
+
* (the close event lands later) — the slot must stay held until then, otherwise
|
|
22
|
+
* a new task would start while the old pnpm still runs in the same profile dir.
|
|
23
|
+
*/
|
|
24
|
+
export declare function hasRunningTask(): boolean;
|
|
25
|
+
/** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
|
|
26
|
+
export declare function hasQueuedTarget(target: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Cancel a queued or running task.
|
|
29
|
+
* - pending: removed from the queue immediately (never spawned).
|
|
30
|
+
* - running: kills the child process; the queue worker then picks the next one.
|
|
31
|
+
* Returns false when the task is unknown or already finished.
|
|
32
|
+
*/
|
|
33
|
+
export declare function cancelTask(id: number): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Enqueue a plugin mutation and return its task. Tasks run strictly serially:
|
|
36
|
+
* the queue worker starts the next one only after the previous finishes.
|
|
37
|
+
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startPluginMutation(options: {
|
|
40
|
+
action: 'add' | 'remove';
|
|
41
|
+
profile: string;
|
|
42
|
+
target: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
/** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
|
|
46
|
+
displayTarget?: string;
|
|
47
|
+
/** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
|
|
48
|
+
uninstallLoader?: LoaderHandle;
|
|
49
|
+
/** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
|
|
50
|
+
attempts?: string[];
|
|
51
|
+
}): InstallTask;
|
|
52
|
+
/**
|
|
53
|
+
* 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
|
|
54
|
+
* git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
|
|
55
|
+
* ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
|
|
56
|
+
* 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
|
|
57
|
+
*/
|
|
58
|
+
export declare function verifyInstalledEntry(profile: string, target: string): {
|
|
59
|
+
name: string | null;
|
|
60
|
+
missing: string | null;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
64
|
+
* pnpm's allowBuilds gate blocks a build script — either the git-hosted
|
|
65
|
+
* package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
|
|
66
|
+
* the printed key@url hint) or a dependency's native build
|
|
67
|
+
* (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
|
|
68
|
+
* exact keys into the profile's pnpm-workspace.yaml and retry once.
|
|
69
|
+
* Other failures fall through to the single-spawn result.
|
|
70
|
+
*/
|
|
71
|
+
export declare function runPluginMutation(options: {
|
|
72
|
+
action: 'add' | 'remove';
|
|
73
|
+
profile: string;
|
|
74
|
+
target: string;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
env?: NodeJS.ProcessEnv;
|
|
77
|
+
task?: InstallTask;
|
|
78
|
+
/** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
|
|
79
|
+
displayTarget?: string;
|
|
80
|
+
/** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
|
|
81
|
+
uninstallLoader?: LoaderHandle;
|
|
82
|
+
}): Promise<InstallResult>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
"url": "git+https://github.com/dshplugin/dsh-plugin-hub.git"
|
|
12
12
|
},
|
|
13
13
|
"homepage": "https://dsh-plugin.org",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/dshplugin/dsh-plugin-hub/issues"
|
|
16
|
+
},
|
|
17
|
+
"author": "DSH-Plugin Hub contributors",
|
|
18
|
+
"funding": {
|
|
19
|
+
"type": "custom",
|
|
20
|
+
"url": "https://dsh-plugin.org"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.6"
|
|
24
|
+
},
|
|
14
25
|
"keywords": [
|
|
15
26
|
"deepseek",
|
|
16
27
|
"deepseek-harness",
|
|
@@ -29,11 +40,21 @@
|
|
|
29
40
|
"open-source"
|
|
30
41
|
],
|
|
31
42
|
"scripts": {
|
|
32
|
-
"build": "npm run build:client",
|
|
33
|
-
"build:
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
43
|
+
"build": "npm run build:server && npm run build:client",
|
|
44
|
+
"build:server": "rm -rf lib && tsc -p tsconfig.server.json",
|
|
45
|
+
"build:client": "tsdown && node scripts/tools/normalize-client-banner.mjs",
|
|
46
|
+
"readme:stats": "node scripts/tools/sync-readme-stats.mjs",
|
|
47
|
+
"reload": "bash scripts/run/reload-dsh.sh",
|
|
48
|
+
"dev": "bash scripts/run/dev-dsh.sh",
|
|
49
|
+
"test": "node --test",
|
|
50
|
+
"test:watch": "node --test --watch",
|
|
51
|
+
"typecheck": "npm run typecheck:client && npm run typecheck:server && npm run typecheck:test",
|
|
52
|
+
"typecheck:client": "tsc -p tsconfig.client.json --noEmit",
|
|
53
|
+
"typecheck:server": "tsc -p tsconfig.server.json --noEmit",
|
|
54
|
+
"typecheck:test": "tsc -p tsconfig.test.json --noEmit",
|
|
55
|
+
"check": "npm run typecheck && npm run test && npm run build",
|
|
56
|
+
"verify:release": "node scripts/tools/verify-release.mjs",
|
|
57
|
+
"prepublishOnly": "npm run verify:release",
|
|
37
58
|
"prepack": "npm run build"
|
|
38
59
|
},
|
|
39
60
|
"dependencies": {},
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar above the list: search input + sort dropdown + installed / not
|
|
3
|
+
* installed filter buttons + the notifications entry. All buttons share the
|
|
4
|
+
* same size (24px high, like the notification entry button).
|
|
5
|
+
*/
|
|
6
|
+
import { createElement as h, Fragment } from 'react'
|
|
7
|
+
import type { FormEvent } from 'react'
|
|
8
|
+
import styles from '../styles/Header.module.css'
|
|
9
|
+
import type { Translate } from '../types.ts'
|
|
10
|
+
import { SORTS } from '../lib/catalog.ts'
|
|
11
|
+
import type { SortKey } from '../lib/catalog.ts'
|
|
12
|
+
import { Dropdown } from './Dropdown.tsx'
|
|
13
|
+
|
|
14
|
+
export function CatalogControls({ query, setQuery, sort, setSort, installedFilter, setInstalledFilter, installedCount, notInstalledCount, t, resultText, noticeCount, onOpenNotifications }: {
|
|
15
|
+
query: string
|
|
16
|
+
setQuery: (value: string) => void
|
|
17
|
+
sort: SortKey
|
|
18
|
+
setSort: (value: SortKey) => void
|
|
19
|
+
installedFilter: 'all' | 'installed' | 'notInstalled'
|
|
20
|
+
setInstalledFilter: (value: 'all' | 'installed' | 'notInstalled') => void
|
|
21
|
+
installedCount: number
|
|
22
|
+
notInstalledCount: number
|
|
23
|
+
t: Translate
|
|
24
|
+
resultText: string | null
|
|
25
|
+
noticeCount: number
|
|
26
|
+
onOpenNotifications: () => void
|
|
27
|
+
}) {
|
|
28
|
+
return h(Fragment, null,
|
|
29
|
+
h('div', { className: styles.searchRow },
|
|
30
|
+
h('input', {
|
|
31
|
+
className: styles.search,
|
|
32
|
+
type: 'search',
|
|
33
|
+
placeholder: t('search'),
|
|
34
|
+
value: query,
|
|
35
|
+
spellCheck: false,
|
|
36
|
+
onInput: (e: FormEvent<HTMLInputElement>) =>
|
|
37
|
+
setQuery((e.target as HTMLInputElement).value),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
h('div', { className: styles.controls },
|
|
41
|
+
// 筛选结果数:位于排序下拉之前,选中分类后提示「筛选出 N 条插件」;
|
|
42
|
+
// 数字单独等宽显示并预留 5 位宽度,数字变化时整行不左右跳动
|
|
43
|
+
resultText ? h('span', { className: styles.filterResults },
|
|
44
|
+
...resultText.split(/(\d+)/).map((part, i) =>
|
|
45
|
+
/^\d+$/.test(part)
|
|
46
|
+
? h('span', { key: i, className: styles.resultCount }, part)
|
|
47
|
+
: part)) : null,
|
|
48
|
+
// 全部 / 已安装 / 未安装:互斥单选组,点击直接切换选中态,「全部」即默认恢复项;
|
|
49
|
+
// 已安装/未安装计数跟随当前分类,为 0 时置灰不可点
|
|
50
|
+
h('button', {
|
|
51
|
+
className: installedFilter === 'all'
|
|
52
|
+
? styles.installedBtnActive
|
|
53
|
+
: styles.installedBtn,
|
|
54
|
+
onClick: () => setInstalledFilter('all'),
|
|
55
|
+
title: t('filterAllHint'),
|
|
56
|
+
'aria-pressed': installedFilter === 'all',
|
|
57
|
+
}, t('all')),
|
|
58
|
+
// 排序下拉框:紧跟「全部」之后,按需切换排序方式
|
|
59
|
+
Dropdown<SortKey>({
|
|
60
|
+
value: sort,
|
|
61
|
+
options: SORTS.map((key) => ({ value: key, label: t(key) })),
|
|
62
|
+
onChange: setSort,
|
|
63
|
+
}),
|
|
64
|
+
h('button', {
|
|
65
|
+
className: installedFilter === 'installed'
|
|
66
|
+
? styles.installedBtnActive
|
|
67
|
+
: installedCount === 0
|
|
68
|
+
? styles.installedBtnDisabled
|
|
69
|
+
: styles.installedBtn,
|
|
70
|
+
onClick: () => setInstalledFilter('installed'),
|
|
71
|
+
disabled: installedCount === 0,
|
|
72
|
+
title: installedCount === 0 ? t('filterInstalledNone') : t('filterInstalledHint'),
|
|
73
|
+
'aria-pressed': installedFilter === 'installed',
|
|
74
|
+
},
|
|
75
|
+
t('installed'),
|
|
76
|
+
h('span', {
|
|
77
|
+
className: installedFilter === 'installed' ? styles.segCountActive : styles.segCount,
|
|
78
|
+
}, installedCount)),
|
|
79
|
+
h('button', {
|
|
80
|
+
className: installedFilter === 'notInstalled'
|
|
81
|
+
? styles.installedBtnActive
|
|
82
|
+
: notInstalledCount === 0
|
|
83
|
+
? styles.installedBtnDisabled
|
|
84
|
+
: styles.installedBtn,
|
|
85
|
+
onClick: () => setInstalledFilter('notInstalled'),
|
|
86
|
+
disabled: notInstalledCount === 0,
|
|
87
|
+
title: notInstalledCount === 0 ? t('filterNotInstalledNone') : t('filterNotInstalledHint'),
|
|
88
|
+
'aria-pressed': installedFilter === 'notInstalled',
|
|
89
|
+
},
|
|
90
|
+
t('notInstalled'),
|
|
91
|
+
h('span', {
|
|
92
|
+
className: installedFilter === 'notInstalled' ? styles.segCountActive : styles.segCount,
|
|
93
|
+
}, notInstalledCount)),
|
|
94
|
+
// 通知中心入口:整行控件最右侧、贴右对齐;有任何通知时右上角悬浮红圈白字计数;
|
|
95
|
+
// 按钮文字用短文案(notificationsBtn),避免英文长标题挤占工具栏空间被截断
|
|
96
|
+
h('button', {
|
|
97
|
+
className: styles.failBtn,
|
|
98
|
+
onClick: onOpenNotifications,
|
|
99
|
+
title: t('notificationsHint'),
|
|
100
|
+
'aria-label': t('notificationsHint'),
|
|
101
|
+
}, t('notificationsBtn'), noticeCount > 0 ? h('span', { className: styles.failBadge }, noticeCount) : null),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section header: brand title row (H1 + open-site button) + tagline, followed
|
|
3
|
+
* by the purple ad banner that promotes the catalog stats.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement as h, Fragment } from 'react'
|
|
6
|
+
import styles from '../styles/Header.module.css'
|
|
7
|
+
import type { Translate } from '../types.ts'
|
|
8
|
+
import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../lib/catalog.ts'
|
|
9
|
+
import { GitHubIcon, LogoIcon } from './icons.tsx'
|
|
10
|
+
|
|
11
|
+
export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang }: {
|
|
12
|
+
t: Translate
|
|
13
|
+
langPath: string
|
|
14
|
+
statsTotal: number
|
|
15
|
+
statsVerified: number
|
|
16
|
+
onToggleLang: () => void
|
|
17
|
+
}) {
|
|
18
|
+
return h(Fragment, null,
|
|
19
|
+
h('div', { className: styles.header },
|
|
20
|
+
// 第一行:logo + 标题(官网链接)与 GitHub 源码图标同行,GitHub 顶到标题行末尾
|
|
21
|
+
h('div', { className: styles.headerTitleRow },
|
|
22
|
+
h('a', {
|
|
23
|
+
className: styles.brandTitle,
|
|
24
|
+
href: `${SITE_URL}${langPath}`,
|
|
25
|
+
target: '_blank',
|
|
26
|
+
rel: 'noopener noreferrer',
|
|
27
|
+
title: t('openHint'),
|
|
28
|
+
'aria-label': t('openHint'),
|
|
29
|
+
},
|
|
30
|
+
h(LogoIcon),
|
|
31
|
+
h('h1', { className: styles.title },
|
|
32
|
+
t('title'),
|
|
33
|
+
h('span', { className: styles.version }, ` v${PLUGIN_VERSION}`),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
// 右侧控件组:语言切换 + GitHub 源码图标(独立链接,brandTitle 是官网 <a> 不能嵌套),
|
|
37
|
+
// 一起 space-between 顶到标题行最右
|
|
38
|
+
h('div', { className: styles.headerRight },
|
|
39
|
+
// 语言切换:按钮文字始终显示「要切到的语言」,点击即切,无需图标
|
|
40
|
+
h('button', {
|
|
41
|
+
className: styles.langBtn,
|
|
42
|
+
type: 'button',
|
|
43
|
+
onClick: onToggleLang,
|
|
44
|
+
title: t('toggleLangHint'),
|
|
45
|
+
'aria-label': t('toggleLangHint'),
|
|
46
|
+
}, langPath === 'zh/' ? 'EN' : '中文'),
|
|
47
|
+
h('a', {
|
|
48
|
+
className: styles.githubLink,
|
|
49
|
+
href: GITHUB_URL,
|
|
50
|
+
target: '_blank',
|
|
51
|
+
rel: 'noopener noreferrer',
|
|
52
|
+
title: t('githubHint'),
|
|
53
|
+
'aria-label': t('githubHint'),
|
|
54
|
+
}, h(GitHubIcon)),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
// 第二行:副标题仍是官网链接,点击跳 dsh-plugin.org
|
|
58
|
+
h('a', {
|
|
59
|
+
className: styles.taglineLink,
|
|
60
|
+
href: `${SITE_URL}${langPath}`,
|
|
61
|
+
target: '_blank',
|
|
62
|
+
rel: 'noopener noreferrer',
|
|
63
|
+
title: t('openHint'),
|
|
64
|
+
},
|
|
65
|
+
h('div', { className: styles.tagline }, t('tagline', { total: statsTotal, verified: statsVerified })),
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
h('a', {
|
|
69
|
+
className: styles.adBanner,
|
|
70
|
+
href: `${SITE_URL}${langPath}`,
|
|
71
|
+
target: '_blank',
|
|
72
|
+
rel: 'noopener noreferrer',
|
|
73
|
+
},
|
|
74
|
+
h('span', { className: styles.adBadge }, t('adBadge')),
|
|
75
|
+
h('span', { className: styles.adText }, t('ad', { total: statsTotal, verified: statsVerified })),
|
|
76
|
+
h('span', { className: styles.adArrow }, '\u2197')),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog list: the scrollable container with the loading / failed / empty
|
|
3
|
+
* states, the plugin cards, and the count footer. Resets scroll position when
|
|
4
|
+
* the category or install-status filter changes so the replaced content is
|
|
5
|
+
* not mistaken for a no-op update.
|
|
6
|
+
*/
|
|
7
|
+
import { createElement as h, useEffect, useRef } from 'react'
|
|
8
|
+
import styles from '../styles/List.module.css'
|
|
9
|
+
import type { HubPlugin, LocaleId, Translate } from '../types.ts'
|
|
10
|
+
import { SITE_URL } from '../lib/catalog.ts'
|
|
11
|
+
import { PluginCard } from './PluginCard.tsx'
|
|
12
|
+
|
|
13
|
+
export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, category, installedFilter, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall }: {
|
|
14
|
+
plugins: HubPlugin[] | null
|
|
15
|
+
failed: boolean
|
|
16
|
+
visible: HubPlugin[]
|
|
17
|
+
total: number
|
|
18
|
+
t: Translate
|
|
19
|
+
langPath: string
|
|
20
|
+
reload: () => void
|
|
21
|
+
category: string
|
|
22
|
+
installedFilter: 'all' | 'installed' | 'notInstalled'
|
|
23
|
+
copied: string | null
|
|
24
|
+
installedName: (p: HubPlugin) => string | null
|
|
25
|
+
installedVersion: (p: HubPlugin) => string | null
|
|
26
|
+
hasUpdate: (p: HubPlugin) => boolean
|
|
27
|
+
langKey: LocaleId
|
|
28
|
+
onInstall: (p: HubPlugin, opts?: { update?: boolean }) => void
|
|
29
|
+
onUninstall: (p: HubPlugin) => void
|
|
30
|
+
}) {
|
|
31
|
+
/** 列表滚动容器:分类/搜索切换后列表内容替换但 scrollTop 保留,会让用户误以为列表没更新,需重置回顶部 */
|
|
32
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
listRef.current?.scrollTo({ top: 0 })
|
|
36
|
+
}, [category, installedFilter])
|
|
37
|
+
|
|
38
|
+
return h('div', { className: styles.body },
|
|
39
|
+
h('div', { ref: listRef, className: styles.list },
|
|
40
|
+
plugins === null && !failed && h('div', { className: styles.state }, t('loading')),
|
|
41
|
+
failed && h('div', { className: styles.state },
|
|
42
|
+
h('div', { className: styles.stateTitle }, t('failed')),
|
|
43
|
+
h('div', { className: styles.stateDesc }, t('failedDesc')),
|
|
44
|
+
h('button', { className: styles.retryBtn, onClick: () => reload() }, t('retry')),
|
|
45
|
+
),
|
|
46
|
+
plugins !== null && !failed && visible.length === 0 && h('div', { className: styles.state },
|
|
47
|
+
h('div', { className: styles.stateTitle }, t('noResult')),
|
|
48
|
+
h('div', { className: styles.stateDesc }, t('noResultDesc')),
|
|
49
|
+
),
|
|
50
|
+
plugins !== null && !failed && visible.map((p) => h(PluginCard, {
|
|
51
|
+
// 唯一 key:数据重构后同一 slug 可能对应多个作者仓库,仅用 slug 会撞 key,
|
|
52
|
+
// 导致切换分类时 React 复用旧 DOM、列表不刷新。
|
|
53
|
+
key: p.ownerSlug ? `${p.ownerSlug}/${p.slug}` : p.slug,
|
|
54
|
+
plugin: p,
|
|
55
|
+
copied,
|
|
56
|
+
installedName,
|
|
57
|
+
installedVersion,
|
|
58
|
+
hasUpdate,
|
|
59
|
+
t,
|
|
60
|
+
langKey,
|
|
61
|
+
langPath,
|
|
62
|
+
onInstall,
|
|
63
|
+
onUninstall,
|
|
64
|
+
})),
|
|
65
|
+
),
|
|
66
|
+
plugins !== null && !failed && h('div', { className: styles.footer },
|
|
67
|
+
h('a', {
|
|
68
|
+
className: styles.footLink,
|
|
69
|
+
href: `${SITE_URL}${langPath}`,
|
|
70
|
+
target: '_blank',
|
|
71
|
+
rel: 'noopener noreferrer',
|
|
72
|
+
}, t('browseAll', { n: total })),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Category tabs: an "all" chip followed by one chip per catalog category.
|
|
3
|
+
* The "all" chip carries the total plugin count; the per-category chips
|
|
4
|
+
* carry no counts so their widths stay uniform.
|
|
5
|
+
*/
|
|
6
|
+
import { createElement as h } from 'react'
|
|
7
|
+
import styles from '../styles/Header.module.css'
|
|
8
|
+
import type { LocaleId } from '../types.ts'
|
|
9
|
+
import { CATEGORY_ORDER, CATEGORY_SHORT_LABELS, categoryLabel } from '../lib/catalog.ts'
|
|
10
|
+
|
|
11
|
+
export function CategoryTabs({ category, setCategory, allLabel, totalCount, langKey }: {
|
|
12
|
+
category: string
|
|
13
|
+
setCategory: (value: string) => void
|
|
14
|
+
allLabel: string
|
|
15
|
+
totalCount: number
|
|
16
|
+
langKey: LocaleId
|
|
17
|
+
}) {
|
|
18
|
+
return h('div', { className: styles.tabs },
|
|
19
|
+
// 第一个按钮固定为「全部」:展示全部分类,直接显示插件总数;
|
|
20
|
+
// 其余分类按钮不带数量,保持排版整齐
|
|
21
|
+
h('button', {
|
|
22
|
+
key: 'all',
|
|
23
|
+
className: category === 'all' ? styles.tabActive : styles.tab,
|
|
24
|
+
onClick: () => setCategory('all'),
|
|
25
|
+
},
|
|
26
|
+
allLabel,
|
|
27
|
+
h('span', { className: styles.tabCount }, totalCount)),
|
|
28
|
+
CATEGORY_ORDER.map((id) => h('button', {
|
|
29
|
+
key: id,
|
|
30
|
+
className: category === id ? styles.tabActive : styles.tab,
|
|
31
|
+
onClick: () => setCategory(id),
|
|
32
|
+
},
|
|
33
|
+
categoryLabel(CATEGORY_SHORT_LABELS, id, langKey))),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom dropdown (native <select> replacement).
|
|
3
|
+
*
|
|
4
|
+
* macOS/浏览器原生下拉无法跟随宿主主题,这里自绘:触发器按钮 + 绝对定位面板,
|
|
5
|
+
* 点外部/Esc 关闭;选项 hover 加重底色、激活项深色实心高亮,全部走 --dsw-alias-* 主题变量。
|
|
6
|
+
*/
|
|
7
|
+
import { createElement as h, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import type { JSX } from 'react'
|
|
9
|
+
import styles from '../styles/Dropdown.module.css'
|
|
10
|
+
import { ChevronDownIcon } from './icons.tsx'
|
|
11
|
+
|
|
12
|
+
export interface DropdownOption<T extends string> {
|
|
13
|
+
value: T
|
|
14
|
+
label: string
|
|
15
|
+
/** 可选计数徽标(如「已安装 12」) */
|
|
16
|
+
count?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DropdownProps<T extends string> {
|
|
20
|
+
value: T
|
|
21
|
+
options: DropdownOption<T>[]
|
|
22
|
+
onChange: (value: T) => void
|
|
23
|
+
title?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Dropdown<T extends string>({ value, options, onChange, title }: DropdownProps<T>): JSX.Element {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
29
|
+
|
|
30
|
+
// 面板展开时:点面板外部或按 Esc 关闭。
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return
|
|
33
|
+
const onDoc = (e: MouseEvent) => {
|
|
34
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
35
|
+
}
|
|
36
|
+
const onKey = (e: KeyboardEvent) => {
|
|
37
|
+
if (e.key === 'Escape') setOpen(false)
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('mousedown', onDoc)
|
|
40
|
+
document.addEventListener('keydown', onKey)
|
|
41
|
+
return () => {
|
|
42
|
+
document.removeEventListener('mousedown', onDoc)
|
|
43
|
+
document.removeEventListener('keydown', onKey)
|
|
44
|
+
}
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
const current = options.find((o) => o.value === value)
|
|
48
|
+
|
|
49
|
+
return h('div', { className: styles.dropdown, ref: rootRef },
|
|
50
|
+
h('button', {
|
|
51
|
+
className: styles.dropdownBtn,
|
|
52
|
+
type: 'button',
|
|
53
|
+
title,
|
|
54
|
+
'aria-haspopup': 'listbox',
|
|
55
|
+
'aria-expanded': open,
|
|
56
|
+
onClick: () => setOpen((v) => !v),
|
|
57
|
+
},
|
|
58
|
+
h('span', { className: styles.dropdownLabel }, current?.label ?? value),
|
|
59
|
+
h('span', { className: open ? styles.dropdownArrowOpen : styles.dropdownArrow }, h(ChevronDownIcon)),
|
|
60
|
+
),
|
|
61
|
+
open && h('div', { className: styles.dropdownPanel, role: 'listbox' },
|
|
62
|
+
options.map((o) => h('button', {
|
|
63
|
+
key: o.value,
|
|
64
|
+
type: 'button',
|
|
65
|
+
role: 'option',
|
|
66
|
+
'aria-selected': o.value === value,
|
|
67
|
+
className: o.value === value ? styles.dropdownItemActive : styles.dropdownItem,
|
|
68
|
+
onClick: () => {
|
|
69
|
+
onChange(o.value)
|
|
70
|
+
setOpen(false)
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
h('span', { className: styles.dropdownItemLabel }, o.label),
|
|
74
|
+
typeof o.count === 'number'
|
|
75
|
+
? h('span', { className: o.value === value ? styles.dropdownCountActive : styles.dropdownCount }, o.count)
|
|
76
|
+
: null,
|
|
77
|
+
)),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
}
|