返回 DeepSeek-Reasonix
skills.astro
根目录 / site / src / pages / skills.astro
1 ---
2 import Base from '../layouts/Base.astro';
3 import SiteHeader from '../components/SiteHeader.astro';
4
5 const base = import.meta.env.BASE_URL.replace(/\/$/, '');
6 const repo = 'https://github.com/esengine/DeepSeek-Reasonix';
7 const desc = "Browse, publish, and install community skills, plugins, and MCP servers for Reasonix. One command to add any capability to your agent — install_source does the rest, SSRF-guarded and risk-rated.";
8 ---
9 <Base
10 title="Reasonix — Skills, Plugins & MCP registry"
11 titleEn="Reasonix — Skills, Plugins & MCP registry"
12 titleZh="Reasonix — 技能、插件与 MCP 市场"
13 description={desc}>
14
15 <SiteHeader active="skills" accountSlot />
16
17 <main>
18 <section class="reg-head">
19 <div class="wrap">
20 <div class="sec-head split">
21 <span class="kicker"><span class="l-en">Registry</span><span class="l-zh">技能市场</span></span>
22 <h2><span class="l-en">Every skill, one <span class="em">install</span> away.</span><span class="l-zh">每个能力,一条 <span class="em">install</span> 就到手。</span></h2>
23 <p><span class="l-en">Community skills, plugins, and MCP servers for your agent. The registry only stores metadata and a source pointer — installs run client-side through <code>install_source</code>, SSRF-guarded and risk-rated. Nothing executes here.</span><span class="l-zh">社区贡献的技能、插件与 MCP 服务器。市场只存元数据与来源指针——安装在你本机经 <code>install_source</code> 执行(带 SSRF 防护与风险分级),这里不运行任何代码。</span></p>
24 </div>
25
26 <div class="reg-toolbar">
27 <div class="reg-search">
28 <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8.5 3a5.5 5.5 0 1 0 3.4 9.8l3.6 3.7 1.4-1.4-3.7-3.6A5.5 5.5 0 0 0 8.5 3Zm0 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z"/></svg>
29 <input id="reg-q" type="search" autocomplete="off" placeholder="Search skills, plugins, MCP servers, tags…" aria-label="Search the registry" />
30 </div>
31 <div class="reg-tabs" data-group="kind" role="tablist">
32 <button class="active" data-kind="all" role="tab"><span class="l-en">All</span><span class="l-zh">全部</span></button>
33 <button data-kind="skill" role="tab">Skills</button>
34 <button data-kind="plugin" role="tab">Plugins</button>
35 <button data-kind="mcp" role="tab">MCP</button>
36 </div>
37 <div class="reg-tabs" data-group="sort" role="tablist">
38 <button class="active" data-sort="new" role="tab"><span class="l-en">New</span><span class="l-zh">最新</span></button>
39 <button data-sort="trending" role="tab"><span class="l-en">Trending</span><span class="l-zh">趋势</span></button>
40 <button data-sort="installs" role="tab"><span class="l-en">Top</span><span class="l-zh">最热</span></button>
41 </div>
42 <a class="btn btn-dark reg-publish-cta" id="publish-open" href="#publish">
43 <span class="l-en">Publish</span><span class="l-zh">发布</span>
44 </a>
45 </div>
46 </div>
47 </section>
48
49 <section class="reg-body">
50 <div class="wrap reg-cols">
51 <div class="reg-main">
52 <div id="pkg-grid" class="pkg-grid" data-state="loading" aria-live="polite">
53 <div class="pkg-skel"></div><div class="pkg-skel"></div><div class="pkg-skel"></div><div class="pkg-skel"></div>
54 </div>
55 <div id="reg-empty" class="reg-state" hidden>
56 <h3><span class="l-en">Be the first to publish.</span><span class="l-zh">来当第一个发布者。</span></h3>
57 <p><span class="l-en">No packages match yet. Ship a capability you built and it lands here for everyone.</span><span class="l-zh">还没有匹配的包。把你构建的能力发上来,所有人都能一键装。</span></p>
58 <a class="btn btn-dark" href="#publish" data-publish-jump><span class="l-en">Publish a package</span><span class="l-zh">发布一个包</span></a>
59 </div>
60 <div id="reg-offline" class="reg-state" hidden>
61 <h3><span class="l-en">Registry is launching soon.</span><span class="l-zh">市场即将上线。</span></h3>
62 <p><span class="l-en">The registry service isn't reachable yet. Sign in to get a handle ready — your packages will be namespaced to it.</span><span class="l-zh">市场服务暂时还连不上。先登录占好你的 handle——你的包会以它作命名空间。</span></p>
63 <a class="btn btn-dark" href="https://id.reasonix.io"><span class="l-en">Sign in at id.reasonix.io</span><span class="l-zh">前往 id.reasonix.io 登录</span></a>
64 </div>
65 </div>
66
67 <aside class="reg-aside">
68 <div class="feed-card">
69 <div class="feed-head"><span class="pulse"></span><span class="l-en">Live activity</span><span class="l-zh">实时动态</span></div>
70 <ul id="feed" class="feed-list"><li class="feed-skel"></li><li class="feed-skel"></li><li class="feed-skel"></li></ul>
71 <div id="feed-empty" class="feed-empty" hidden><span class="l-en">Quiet for now — the first publish shows up here.</span><span class="l-zh">暂时安静——第一个发布会出现在这里。</span></div>
72 </div>
73 </aside>
74 </div>
75 </section>
76
77 <section class="reg-review" id="review" hidden>
78 <div class="wrap">
79 <div class="review-head">
80 <span class="kicker"><span class="l-en">Review queue</span><span class="l-zh">审核队列</span></span>
81 <span class="review-count" id="review-count"></span>
82 </div>
83 <div id="review-list" class="review-list"></div>
84 <div id="review-empty" class="review-empty" hidden><span class="l-en">Nothing pending — all caught up.</span><span class="l-zh">没有待审——都处理完了。</span></div>
85 </div>
86 </section>
87
88 <section class="reg-publish" id="publish" hidden>
89 <div class="wrap">
90 <div class="pub-card">
91 <div class="pub-head">
92 <div>
93 <h3><span class="l-en">Publish a package</span><span class="l-zh">发布一个包</span></h3>
94 <p><span class="l-en">Verified accounts only. Submissions are reviewed before they go live; your package is namespaced to your handle (<code>@you/name</code>).</span><span class="l-zh">仅已验证邮箱的账号可提交。提交后经管理员审核才会公开;包以你的 handle 命名(<code>@you/name</code>)。</span></p>
95 </div>
96 <button type="button" class="pub-close" id="publish-close" aria-label="Close">✕</button>
97 </div>
98 <form id="pub-form" class="pub-form" autocomplete="off">
99 <div class="pub-row">
100 <label><span class="l-en">Kind</span><span class="l-zh">类型</span>
101 <select name="kind"><option value="skill">skill</option><option value="plugin">plugin</option><option value="mcp">mcp</option></select>
102 </label>
103 <label><span class="l-en">Name</span><span class="l-zh">名称</span>
104 <input name="name" required placeholder="my-package" pattern="[a-z0-9][a-z0-9._-]*" />
105 </label>
106 <label><span class="l-en">Version</span><span class="l-zh">版本</span>
107 <input name="version" placeholder="0.1.0" />
108 </label>
109 </div>
110 <label><span class="l-en">One-line summary</span><span class="l-zh">一句话简介</span>
111 <input name="summary" maxlength="200" placeholder="What does it do?" />
112 </label>
113 <label><span class="l-en">Source</span><span class="l-zh">来源</span>
114 <input name="source" required pattern="(https?://[^ ]+|git:github[.]com/[^ ]+|@?[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)?)" title="A URL (SKILL.md, a Reasonix/Codex/Claude plugin repository, .mcp.json, repository path), a git:github.com/… shorthand, or an MCP package name — not free text or a local path." placeholder="https://… SKILL.md · plugin repo · .mcp.json · MCP package" />
115 <small class="pub-hint"><span class="l-en">Plugins must point to a GitHub repository or path containing <code>reasonix-plugin.json</code>, <code>.codex-plugin/plugin.json</code>, <code>.claude-plugin/plugin.json</code>, or a supported <code>.claude-plugin/marketplace.json</code>.</span><span class="l-zh">插件必须指向包含 <code>reasonix-plugin.json</code>、<code>.codex-plugin/plugin.json</code>、<code>.claude-plugin/plugin.json</code>,或受支持的 <code>.claude-plugin/marketplace.json</code> 的 GitHub 仓库或路径。</span></small>
116 </label>
117 <div class="pub-row">
118 <label><span class="l-en">Repo (optional)</span><span class="l-zh">仓库(可选)</span>
119 <input name="repoUrl" placeholder="https://github.com/…" />
120 </label>
121 <label><span class="l-en">Tags (comma-separated)</span><span class="l-zh">标签(逗号分隔)</span>
122 <input name="tags" placeholder="testing, go, review" />
123 </label>
124 </div>
125 <div class="pub-actions">
126 <button type="submit" class="btn btn-dark"><span class="l-en">Publish</span><span class="l-zh">发布</span></button>
127 <span id="pub-msg" class="pub-msg" role="status"></span>
128 </div>
129 </form>
130 </div>
131 </div>
132 </section>
133 </main>
134
135 <footer>
136 <div class="foot-inner">
137 <a class="brand" href={`${base}/`}><img src={`${base}/logo.svg`} alt="Reasonix logo" /><span>Reasonix</span></a>
138 <span><span class="l-en">© 2026 Reasonix · MIT licensed · built to be left running.</span><span class="l-zh">© 2026 Reasonix · MIT 许可 · 为常驻运行而生。</span></span>
139 <nav class="foot-links">
140 <a href={repo}>GitHub</a>
141 <a href={`${base}/`}><span class="l-en">Home</span><span class="l-zh">首页</span></a>
142 <a href={`${base}/docs/`}><span class="l-en">Docs</span><span class="l-zh">文档</span></a>
143 <a href="https://id.reasonix.io"><span class="l-en">Sign in</span><span class="l-zh">登录</span></a>
144 </nav>
145 </div>
146 </footer>
147
148 <style is:global>
149 .reg-head { padding: 128px 0 0; }
150 .reg-toolbar {
151 display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
152 margin-top: 32px; padding-bottom: 4px;
153 }
154 .reg-search {
155 display: flex; align-items: center; gap: 8px; flex: 1 1 240px;
156 background: var(--bg-soft); border-radius: 999px; padding: 0 18px;
157 border: 1px solid transparent; transition: border-color .2s, background .2s;
158 }
159 .reg-search:focus-within { background: var(--card); border-color: var(--line); box-shadow: var(--shadow-1); }
160 .reg-search svg { width: 18px; height: 18px; fill: var(--ink-3); flex: none; }
161 .reg-search input {
162 border: none; background: none; outline: none; width: 100%;
163 font-family: var(--font-sans); font-size: 15px; color: var(--ink); padding: 12px 0;
164 }
165 .reg-tabs { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg-soft); border-radius: 999px; }
166 .reg-tabs button {
167 font-family: var(--font-sans); font-size: 13.5px; font-weight: 500; color: var(--ink-2);
168 background: transparent; border: none; cursor: pointer; padding: 8px 16px; border-radius: 999px;
169 transition: background .2s, color .2s, box-shadow .2s;
170 }
171 .reg-tabs button:hover { color: var(--ink); }
172 .reg-tabs button.active { background: var(--card); color: var(--accent); box-shadow: var(--shadow-1); }
173
174 .reg-body { padding: 36px 0 96px; }
175 .reg-cols { display: grid; grid-template-columns: 1fr 320px; gap: 32px; align-items: start; }
176 .reg-main { min-width: 0; }
177 .pkg-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
178 .pkg-grid[data-state="loading"] .pkg-skel { display: block; }
179
180 .pkg-card {
181 display: flex; flex-direction: column; gap: 12px;
182 background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
183 padding: 22px; min-width: 0; transition: box-shadow .25s, border-color .25s, transform .25s;
184 }
185 .pkg-card:hover { box-shadow: var(--shadow-2); border-color: transparent; transform: translateY(-2px); }
186 .pkg-top { display: flex; align-items: center; gap: 8px; }
187 .pkg-kind { font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); background: var(--accent-soft); padding: 4px 9px; border-radius: 999px; }
188 .pkg-kind.plugin { color: oklch(0.52 0.14 55); background: color-mix(in oklch, oklch(0.52 0.14 55) 10%, var(--card)); }
189 .pkg-kind.mcp { color: oklch(0.5 0.16 300); background: color-mix(in oklch, oklch(0.5 0.16 300) 9%, var(--card)); }
190 .pkg-badge { font-size: 11px; font-weight: 600; padding: 4px 9px; border-radius: 999px; }
191 .pkg-badge.verified { color: oklch(0.5 0.13 155); background: color-mix(in oklch, oklch(0.5 0.13 155) 10%, var(--card)); }
192 .pkg-badge.community { color: var(--ink-3); background: var(--bg-soft); }
193 .pkg-stars { margin-left: auto; font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
194 .pkg-name { font-size: 17px; font-weight: 600; letter-spacing: -.01em; overflow-wrap: anywhere; }
195 .pkg-name .scope { color: var(--ink-3); font-weight: 500; }
196 .pkg-sum { font-size: 14px; line-height: 1.55; color: var(--ink-2); overflow-wrap: anywhere; }
197 .pkg-tags { display: flex; flex-wrap: wrap; gap: 6px; }
198 .pkg-tags span { font-size: 12px; color: var(--ink-3); background: var(--bg-soft); padding: 3px 10px; border-radius: 999px; }
199 .pkg-foot { display: flex; align-items: center; gap: 12px; margin-top: 2px; }
200 .pkg-install {
201 display: inline-flex; align-items: center; gap: 10px; flex: 1; min-width: 0;
202 font-family: var(--font-mono); font-size: 12.5px; color: var(--ink);
203 background: var(--bg-soft); border-radius: 999px; padding: 8px 8px 8px 14px;
204 }
205 .pkg-install code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
206 .pkg-install button {
207 margin-left: auto; flex: none; font-family: var(--font-sans); font-size: 12px; font-weight: 600;
208 background: var(--card); color: var(--accent); border: none; cursor: pointer; padding: 6px 13px;
209 border-radius: 999px; box-shadow: var(--shadow-1); transition: background .2s, color .2s;
210 }
211 .pkg-install button.copied { background: oklch(0.55 0.14 155); color: #fff; }
212 .pkg-meta { font-size: 12.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
213
214 .pkg-skel, .feed-skel { display: none; border-radius: var(--radius); background: linear-gradient(100deg, var(--bg-soft) 30%, var(--line) 50%, var(--bg-soft) 70%); background-size: 200% 100%; animation: sheen 1.2s linear infinite; }
215 .pkg-skel { height: 168px; }
216 .feed-skel { height: 44px; border-radius: 12px; margin-bottom: 10px; }
217 @keyframes sheen { to { background-position: -200% 0; } }
218
219 .reg-state { text-align: center; padding: 56px 20px; border: 1px dashed var(--line); border-radius: var(--radius); }
220 .reg-state h3 { font-size: 20px; font-weight: 600; }
221 .reg-state p { margin: 10px auto 22px; max-width: 40ch; color: var(--ink-2); font-size: 15px; line-height: 1.55; }
222
223 .reg-aside { position: sticky; top: 88px; }
224 .feed-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
225 .feed-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 14px; }
226 .pulse { width: 8px; height: 8px; border-radius: 50%; background: oklch(0.7 0.16 150); box-shadow: 0 0 0 0 oklch(0.7 0.16 150 / .5); animation: pulse 2s var(--ease) infinite; }
227 @keyframes pulse { 70%, 100% { box-shadow: 0 0 0 7px oklch(0.7 0.16 150 / 0); } }
228 .feed-list { list-style: none; display: flex; flex-direction: column; gap: 2px; }
229 .feed-item { display: flex; flex-direction: column; gap: 2px; padding: 9px 0; border-top: 1px solid var(--line); font-size: 13px; }
230 .feed-item:first-child { border-top: none; }
231 .feed-item .fx { color: var(--ink); line-height: 1.45; }
232 .feed-item .fx b { font-weight: 600; }
233 .feed-item .fx a { color: var(--accent); text-decoration: none; }
234 .feed-item .fx a:hover { text-decoration: underline; }
235 .feed-item .ft { color: var(--ink-3); font-size: 11.5px; }
236 .feed-item .ico { font-family: var(--font-mono); }
237 .feed-empty { font-size: 13px; color: var(--ink-3); line-height: 1.5; }
238
239 .nav-acct { display: inline-flex; align-items: center; }
240 .acct { display: inline-flex; align-items: center; gap: 10px; }
241 .acct-name { font-size: 14px; font-weight: 600; color: var(--ink); text-decoration: none; }
242 .acct-name:hover { color: var(--accent); }
243 .acct-logout { font-family: var(--font-sans); font-size: 13px; font-weight: 500; color: var(--ink-2); background: var(--bg-soft); border: none; cursor: pointer; padding: 8px 14px; border-radius: 999px; transition: background .2s, color .2s; }
244 .acct-logout:hover { color: var(--ink); background: var(--line); }
245 .acct-warn { font-size: 11px; font-weight: 600; color: oklch(0.52 0.15 60); background: color-mix(in oklch, oklch(0.7 0.16 60) 16%, var(--card)); padding: 3px 9px; border-radius: 999px; text-decoration: none; }
246 .reg-review { padding: 8px 0 40px; }
247 .review-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
248 .review-count { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
249 .review-list { display: flex; flex-direction: column; gap: 12px; }
250 .review-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; background: color-mix(in oklch, oklch(0.7 0.16 60) 6%, var(--card)); border: 1px solid color-mix(in oklch, oklch(0.7 0.16 60) 30%, var(--card)); border-radius: var(--radius-sm); padding: 14px 16px; }
251 .review-row .rv-main { flex: 1; min-width: 0; }
252 .review-row .rv-name { font-size: 15px; font-weight: 600; }
253 .review-row .rv-name .scope { color: var(--ink-3); font-weight: 500; }
254 .review-row .rv-sum { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow-wrap: anywhere; }
255 .review-row .rv-src { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 4px; overflow-wrap: anywhere; }
256 .review-row .rv-acts { display: inline-flex; gap: 8px; flex: none; }
257 .rv-btn { font-family: var(--font-sans); font-size: 13px; font-weight: 600; border: none; cursor: pointer; padding: 8px 14px; border-radius: 999px; transition: background .2s, color .2s, box-shadow .2s; }
258 .rv-btn.approve { background: oklch(0.55 0.13 155); color: #fff; }
259 .rv-btn.reject { background: var(--bg-soft); color: oklch(0.5 0.18 25); }
260 .rv-btn.verify { background: var(--card); color: var(--accent); box-shadow: var(--shadow-1); }
261 .rv-btn:disabled { opacity: .5; cursor: default; }
262 .review-empty { font-size: 14px; color: var(--ink-3); padding: 28px 0; }
263
264 .reg-publish { padding: 8px 0 64px; scroll-margin-top: 88px; }
265 .pub-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; box-shadow: var(--shadow-2); }
266 .pub-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
267 .pub-head h3 { font-size: 20px; font-weight: 600; }
268 .pub-head p { margin-top: 6px; color: var(--ink-2); font-size: 14px; }
269 .pub-close { background: var(--bg-soft); border: none; cursor: pointer; width: 32px; height: 32px; border-radius: 50%; color: var(--ink-2); font-size: 14px; flex: none; }
270 .pub-close:hover { color: var(--ink); }
271 .pub-form { display: flex; flex-direction: column; gap: 14px; }
272 .pub-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
273 .pub-form label { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
274 .pub-hint { color: var(--ink-3); font-size: 11.5px; font-weight: 400; line-height: 1.45; }
275 .pub-form input, .pub-form select {
276 font-family: var(--font-sans); font-size: 14px; color: var(--ink); font-weight: 400;
277 background: var(--bg-soft); border: 1px solid transparent; border-radius: var(--radius-sm);
278 padding: 11px 14px; outline: none; transition: border-color .2s, background .2s;
279 }
280 .pub-form input:focus, .pub-form select:focus { background: var(--card); border-color: var(--accent); }
281 .pub-actions { display: flex; align-items: center; gap: 16px; margin-top: 4px; }
282 .pub-msg { font-size: 13.5px; color: var(--ink-2); }
283 .pub-msg.err { color: oklch(0.55 0.18 25); }
284 .pub-msg.ok { color: oklch(0.5 0.13 155); }
285
286 @media (max-width: 900px) {
287 .reg-cols { grid-template-columns: 1fr; }
288 .reg-aside { position: static; }
289 .pkg-grid { grid-template-columns: 1fr; }
290 .pub-row { grid-template-columns: 1fr; }
291 }
292 @media (max-width: 560px) {
293 .reg-head { padding-top: 104px; }
294 .wrap { padding: 0 20px; }
295 .reg-toolbar { gap: 10px; }
296 .reg-tabs { flex: 1 1 auto; justify-content: center; }
297 .pkg-card { padding: 18px; }
298 .pub-card { padding: 20px; }
299 }
300 </style>
301
302 <script>
303 import { currentAccount, accountLogout } from '../scripts/auth.js';
304 const REGISTRY = import.meta.env.PUBLIC_REGISTRY_ORIGIN || 'https://crash.reasonix.io';
305 const state = { kind: 'all', sort: 'new', q: '' };
306 let currentUser = null;
307
308 const grid = document.getElementById('pkg-grid');
309 const emptyEl = document.getElementById('reg-empty');
310 const offlineEl = document.getElementById('reg-offline');
311 const feedEl = document.getElementById('feed');
312 const feedEmpty = document.getElementById('feed-empty');
313
314 const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (m) => (
315 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]
316 ));
317 const kindClass = (kind) => `pkg-kind${kind === 'plugin' ? ' plugin' : kind === 'mcp' ? ' mcp' : ''}`;
318 const installRequest = (p) =>
319 `Install this Reasonix ${p.kind} package from ${p.source}. Use install_source with kind="${p.kind}".`;
320
321 function show(el, on) { if (el) el.hidden = !on; }
322
323 function card(p) {
324 const badge = p.verified
325 ? '<span class="pkg-badge verified">✓ verified</span>'
326 : '<span class="pkg-badge community">community</span>';
327 const tags = (p.tags || []).slice(0, 4).map((t) => `<span>${esc(t)}</span>`).join('');
328 const stars = p.starCount > 0 ? `<span class="pkg-stars">★ ${p.starCount}</span>` : '';
329 const repo = p.repoUrl ? ` · <a href="${esc(p.repoUrl)}" target="_blank" rel="noreferrer">repo</a>` : '';
330 return `<article class="pkg-card">
331 <div class="pkg-top"><span class="${kindClass(p.kind)}">${esc(p.kind)}</span>${badge}${stars}</div>
332 <div class="pkg-name"><span class="scope">${esc(p.handle)}/</span>${esc(p.name)}</div>
333 <div class="pkg-sum">${esc(p.summary) || '<span style="color:var(--ink-3)">No summary.</span>'}</div>
334 ${tags ? `<div class="pkg-tags">${tags}</div>` : ''}
335 <div class="pkg-foot">
336 <span class="pkg-install" title="Install source — give it to Reasonix"><code>${esc(p.source)}</code><button data-copy="${esc(installRequest(p))}" data-slug="${esc(p.handle)}/${esc(p.name)}">Copy</button></span>
337 </div>
338 <div class="pkg-meta">↓ ${p.installCount || 0} installs · v${esc(p.latestVersion) || '—'}${repo}</div>
339 </article>`;
340 }
341
342 function renderPackages(list) {
343 grid.removeAttribute('data-state');
344 show(offlineEl, false);
345 if (!list.length) { grid.innerHTML = ''; show(emptyEl, true); return; }
346 show(emptyEl, false);
347 grid.innerHTML = list.map(card).join('');
348 }
349
350 function renderOffline() {
351 grid.removeAttribute('data-state');
352 grid.innerHTML = '';
353 show(emptyEl, false);
354 show(offlineEl, true);
355 }
356
357 const rel = (iso) => {
358 const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
359 if (s < 60) return 'just now';
360 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
361 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
362 return `${Math.floor(s / 86400)}d ago`;
363 };
364
365 function feedLine(e) {
366 const who = e.actor ? `<b>@${esc(e.actor)}</b> ` : '';
367 const link = e.slug ? `<a href="#" data-slug="${esc(e.slug)}">${esc(e.slug)}</a>` : '';
368 const verb = { publish: 'published', update: 'updated', star: 'starred', milestone: '' };
369 const ico = { publish: '↑', update: '↻', star: '★', milestone: '◎' }[e.type] || '•';
370 const text = e.type === 'milestone' ? esc(e.summary) : `${who}${verb[e.type] || e.type} ${link}`;
371 return `<li class="feed-item"><span class="fx"><span class="ico">${ico}</span> ${text}</span><span class="ft">${rel(e.createdAt)}</span></li>`;
372 }
373
374 function renderFeed(events) {
375 if (!events.length) { feedEl.innerHTML = ''; show(feedEmpty, true); return; }
376 show(feedEmpty, false);
377 feedEl.innerHTML = events.map(feedLine).join('');
378 }
379
380 async function loadPackages() {
381 const params = new URLSearchParams({ kind: state.kind, sort: state.sort, q: state.q, limit: '48' });
382 try {
383 const r = await fetch(`${REGISTRY}/v1/packages?${params}`);
384 if (!r.ok) throw new Error(String(r.status));
385 renderPackages((await r.json()).packages || []);
386 } catch { renderOffline(); }
387 }
388
389 async function loadFeed() {
390 try {
391 const r = await fetch(`${REGISTRY}/v1/activity?limit=20`);
392 if (!r.ok) throw new Error(String(r.status));
393 renderFeed((await r.json()).events || []);
394 } catch { feedEl.innerHTML = ''; show(feedEmpty, true); }
395 }
396
397 // Copy install command (cards render after page load, so delegate).
398 grid.addEventListener('click', (ev) => {
399 const btn = ev.target.closest('button[data-copy]');
400 if (!btn) return;
401 navigator.clipboard?.writeText(btn.dataset.copy);
402 btn.classList.add('copied');
403 btn.textContent = '✓';
404 setTimeout(() => { btn.classList.remove('copied'); btn.textContent = 'Copy'; }, 1400);
405 // Best-effort install ping — powers the trending rank.
406 fetch(`${REGISTRY}/v1/packages/${btn.dataset.slug}/installed`, { method: 'POST' }).catch(() => {});
407 });
408
409 // Toolbar
410 let debounce;
411 document.getElementById('reg-q').addEventListener('input', (e) => {
412 clearTimeout(debounce);
413 state.q = e.target.value.trim();
414 debounce = setTimeout(loadPackages, 220);
415 });
416 document.querySelectorAll('.reg-tabs').forEach((group) => {
417 group.addEventListener('click', (ev) => {
418 const btn = ev.target.closest('button');
419 if (!btn) return;
420 group.querySelectorAll('button').forEach((b) => b.classList.remove('active'));
421 btn.classList.add('active');
422 if (group.dataset.group === 'kind') state.kind = btn.dataset.kind;
423 else state.sort = btn.dataset.sort;
424 loadPackages();
425 });
426 });
427
428 // Publish panel — sign-in gated: send anonymous visitors to /login, and warn
429 // a signed-in-but-unverified user that publishing needs a confirmed email.
430 const publish = document.getElementById('publish');
431 const loginNext = () => { location.href = '/login?next=' + encodeURIComponent('/skills/'); };
432 const openPublish = (ev) => {
433 if (ev) ev.preventDefault();
434 if (!currentUser) return loginNext();
435 publish.hidden = false;
436 if (!currentUser.emailVerified) setMsg('Verify your email before publishing — check your inbox.', 'err');
437 publish.scrollIntoView({ behavior: 'smooth' });
438 };
439 document.getElementById('publish-open').addEventListener('click', openPublish);
440 document.querySelectorAll('[data-publish-jump]').forEach((el) => el.addEventListener('click', openPublish));
441 document.getElementById('publish-close').addEventListener('click', () => { publish.hidden = true; });
442 if (location.hash === '#publish') publish.hidden = false;
443
444 const msg = document.getElementById('pub-msg');
445 const setMsg = (text, cls) => { msg.textContent = text; msg.className = 'pub-msg' + (cls ? ' ' + cls : ''); };
446
447 document.getElementById('pub-form').addEventListener('submit', async (ev) => {
448 ev.preventDefault();
449 const f = new FormData(ev.target);
450 const tags = String(f.get('tags') || '').split(',').map((t) => t.trim()).filter(Boolean);
451 const body = {
452 kind: f.get('kind'), name: f.get('name'), summary: f.get('summary') || '',
453 source: f.get('source'), installKind: f.get('kind'), version: f.get('version') || '',
454 repoUrl: f.get('repoUrl') || '', tags,
455 };
456 setMsg('Publishing…');
457 try {
458 const r = await fetch(`${REGISTRY}/v1/packages`, {
459 method: 'POST', credentials: 'include',
460 headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
461 });
462 const data = await r.json().catch(() => ({}));
463 if (r.status === 401) return loginNext();
464 if (!r.ok) { setMsg(data?.error?.message || 'Publish failed.', 'err'); return; }
465 const pending = data.package?.status === 'pending';
466 setMsg(pending
467 ? `Submitted ${data.package.slug} for review — an admin approves it before it goes live.`
468 : `Published ${data.package.slug}@${data.version}.`, 'ok');
469 ev.target.reset();
470 loadPackages(); loadFeed();
471 } catch { setMsg('Registry is unreachable right now.', 'err'); }
472 });
473
474 // Admin review queue — only initialised for admins.
475 const reviewSection = document.getElementById('review');
476 const reviewList = document.getElementById('review-list');
477 const reviewCount = document.getElementById('review-count');
478 const reviewEmpty = document.getElementById('review-empty');
479
480 function renderReview(pkgs) {
481 reviewCount.textContent = pkgs.length ? `${pkgs.length} pending` : '';
482 if (!pkgs.length) { reviewList.innerHTML = ''; show(reviewEmpty, true); return; }
483 show(reviewEmpty, false);
484 reviewList.innerHTML = pkgs.map((p) => `
485 <div class="review-row" data-slug="${esc(p.handle)}/${esc(p.name)}" data-version="${esc(p.latestVersion)}" data-updated-at="${esc(p.updatedAt)}" data-status="${esc(p.status)}">
486 <div class="rv-main">
487 <div class="rv-name"><span class="scope">${esc(p.handle)}/</span>${esc(p.name)} <span class="${kindClass(p.kind)}">${esc(p.kind)}</span></div>
488 <div class="rv-sum">${esc(p.summary) || '(no summary)'}</div>
489 <div class="rv-src">${esc(p.source)}</div>
490 </div>
491 <div class="rv-acts">
492 <button class="rv-btn approve" data-act="approve">Approve</button>
493 <button class="rv-btn reject" data-act="reject">Reject</button>
494 </div>
495 </div>`).join('');
496 }
497
498 async function loadReview() {
499 try {
500 const r = await fetch(`${REGISTRY}/v1/admin/packages?status=pending`, { credentials: 'include' });
501 if (!r.ok) throw new Error(String(r.status));
502 renderReview((await r.json()).packages || []);
503 } catch { reviewSection.hidden = true; }
504 }
505
506 reviewList.addEventListener('click', async (ev) => {
507 const btn = ev.target.closest('.rv-btn');
508 if (!btn) return;
509 const row = btn.closest('.review-row');
510 row.querySelectorAll('.rv-btn').forEach((b) => (b.disabled = true));
511 try {
512 const r = await fetch(`${REGISTRY}/v1/admin/packages/${row.dataset.slug}/${btn.dataset.act}`, {
513 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' },
514 body: JSON.stringify({
515 expectedVersion: row.dataset.version,
516 expectedUpdatedAt: row.dataset.updatedAt,
517 expectedStatus: row.dataset.status,
518 }),
519 });
520 const data = await r.json().catch(() => ({}));
521 if (!r.ok) {
522 row.querySelectorAll('.rv-btn').forEach((b) => (b.disabled = false));
523 setMsg(data?.error?.message || 'Review action failed.', 'err');
524 if (r.status === 409) loadReview();
525 return;
526 }
527 row.remove();
528 loadReview(); loadPackages(); loadFeed();
529 } catch {
530 row.querySelectorAll('.rv-btn').forEach((b) => (b.disabled = false));
531 setMsg('Registry is unreachable right now.', 'err');
532 }
533 });
534
535 // Nav account state, via the site's shared account client. Signed out → a
536 // sign-in link; signed in → the handle (linking to /account) + a log-out control.
537 async function mountAccount() {
538 const slot = document.querySelector('[data-account-slot]');
539 const user = await currentAccount();
540 if (!user) {
541 slot.innerHTML = `<a class="btn btn-ghost" href="/login/?next=${encodeURIComponent('/skills/')}"><span class="l-en">Sign in</span><span class="l-zh">登录</span></a>`;
542 return null;
543 }
544 const warn = user.emailVerified ? '' : `<a class="acct-warn" href="/account/"><span class="l-en">unverified</span><span class="l-zh">未验证</span></a>`;
545 slot.innerHTML = `<span class="acct">${warn}<a class="acct-name" href="/account/">@${esc(user.handle)}</a><button type="button" class="acct-logout"><span class="l-en">Log out</span><span class="l-zh">退出</span></button></span>`;
546 slot.querySelector('.acct-logout').addEventListener('click', async () => { await accountLogout(); location.reload(); });
547 return user;
548 }
549
550 mountAccount().then((user) => {
551 currentUser = user;
552 if (user && user.role === 'admin') { reviewSection.hidden = false; loadReview(); }
553 });
554
555 loadPackages();
556 loadFeed();
557 </script>
558 </Base>
559
559 lines Plain Text