ework-web 0.10.29 → 0.10.31
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/package.json +1 -1
- package/src/auth.ts +11 -1
- package/src/config.ts +2 -0
- package/src/index.ts +7 -5
- package/src/store.ts +48 -0
- package/src/views/issueList.ts +38 -8
- package/src/views/issues.ts +25 -5
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Config } from "./config";
|
|
2
2
|
import { getUserByLogin, ensureUser, verifyPat, type UserRow } from "./store";
|
|
3
3
|
import { runAuthHook } from "./auth-hook";
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
5
|
|
|
5
6
|
// Per-user token-cookie auth. Cookie value is HMAC-signed and carries login +
|
|
6
7
|
// issued-at, so the server is stateless (no session table). Two cookie formats
|
|
@@ -191,7 +192,16 @@ function esc(s: string): string {
|
|
|
191
192
|
.replace(/"/g, """);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
export function loginHTML(next: string, error?: string): string {
|
|
195
|
+
export function loginHTML(next: string, error?: string, cfg?: Config): string {
|
|
196
|
+
if (cfg?.loginPage && existsSync(cfg.loginPage)) {
|
|
197
|
+
try {
|
|
198
|
+
const tpl = readFileSync(cfg.loginPage, "utf-8");
|
|
199
|
+
return tpl
|
|
200
|
+
.replace(/\{\{next\}\}/g, esc(sanitizeNext(next)))
|
|
201
|
+
.replace(/\{\{error\}\}/g, error ? esc(error) : "");
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
195
205
|
const err = error ? `<div class="err">${esc(error)}</div>` : "";
|
|
196
206
|
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
197
207
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
package/src/config.ts
CHANGED
|
@@ -81,6 +81,7 @@ export const configSchema = z.object({
|
|
|
81
81
|
routerAdminToken: z.string().default(""),
|
|
82
82
|
internalAuthHook: z.string().default(""),
|
|
83
83
|
userAuthHook: z.string().default(""),
|
|
84
|
+
loginPage: z.string().default(""),
|
|
84
85
|
// Default "provider/model" string passed to `opencode run --model <X>`.
|
|
85
86
|
// Empty = let opencode pick per its own opencode.json + env. ework-daemon
|
|
86
87
|
// pushes this (or the per-project override) on every spawn to defend
|
|
@@ -183,6 +184,7 @@ export async function loadConfig(): Promise<Config> {
|
|
|
183
184
|
routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
|
|
184
185
|
internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
|
|
185
186
|
userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
|
|
187
|
+
loginPage: process.env.WORK_LOGIN_PAGE ?? "",
|
|
186
188
|
defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
|
|
187
189
|
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
188
190
|
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
package/src/index.ts
CHANGED
|
@@ -481,7 +481,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
481
481
|
const form = await req.formData().catch(() => new FormData());
|
|
482
482
|
const next = sanitizeNext(String(form.get("next") ?? "/"));
|
|
483
483
|
if (!rateLimit(`login:${ip}`, 5, 5 / (15 * 60))) {
|
|
484
|
-
return html(loginHTML(next, "尝试过多,15 分钟后再试"), 429);
|
|
484
|
+
return html(loginHTML(next, "尝试过多,15 分钟后再试", cfg), 429);
|
|
485
485
|
}
|
|
486
486
|
const login = String(form.get("login") ?? "").trim();
|
|
487
487
|
const password = String(form.get("password") ?? "");
|
|
@@ -516,10 +516,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
516
516
|
: login || password
|
|
517
517
|
? "用户名或密码错误"
|
|
518
518
|
: "请填写用户名密码或共享 token";
|
|
519
|
-
return html(loginHTML(next, err), 401);
|
|
519
|
+
return html(loginHTML(next, err, cfg), 401);
|
|
520
520
|
}
|
|
521
521
|
const next = sanitizeNext(url.searchParams.get("next") ?? "/");
|
|
522
|
-
return html(loginHTML(next));
|
|
522
|
+
return html(loginHTML(next, undefined, cfg));
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
if (url.pathname === "/logout" && req.method === "POST") {
|
|
@@ -611,8 +611,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
611
611
|
if (url.pathname === "/issues") {
|
|
612
612
|
const state = parseState(url.searchParams.get("state"));
|
|
613
613
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
614
|
+
const label = url.searchParams.get("label")?.trim() ?? "";
|
|
614
615
|
try {
|
|
615
|
-
return html(await buildIssuesFeed(state, q));
|
|
616
|
+
return html(await buildIssuesFeed(state, q, label));
|
|
616
617
|
} catch (e) {
|
|
617
618
|
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
618
619
|
}
|
|
@@ -1891,8 +1892,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1891
1892
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1892
1893
|
const state = parseState(url.searchParams.get("state"));
|
|
1893
1894
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
1895
|
+
const label = url.searchParams.get("label")?.trim() ?? "";
|
|
1894
1896
|
try {
|
|
1895
|
-
return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
|
|
1897
|
+
return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q, label));
|
|
1896
1898
|
} catch (e) {
|
|
1897
1899
|
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
1898
1900
|
}
|
package/src/store.ts
CHANGED
|
@@ -330,6 +330,7 @@ export async function getIssueWithMeta(projectId: number, number: number): Promi
|
|
|
330
330
|
export interface ListIssuesOpts {
|
|
331
331
|
state?: "open" | "closed" | "all";
|
|
332
332
|
q?: string;
|
|
333
|
+
label?: string;
|
|
333
334
|
limit?: number;
|
|
334
335
|
}
|
|
335
336
|
|
|
@@ -351,6 +352,11 @@ export async function listIssues(projectId: number, opts: ListIssuesOpts = {}):
|
|
|
351
352
|
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
|
|
352
353
|
args.push(like, like);
|
|
353
354
|
}
|
|
355
|
+
const label = (opts.label ?? "").trim();
|
|
356
|
+
if (label) {
|
|
357
|
+
sql += " AND EXISTS (SELECT 1 FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)";
|
|
358
|
+
args.push(label);
|
|
359
|
+
}
|
|
354
360
|
sql += " ORDER BY i.updated_at DESC LIMIT ?";
|
|
355
361
|
args.push(limit);
|
|
356
362
|
return await getDB().all<IssueWithMeta>(sql, args);
|
|
@@ -374,6 +380,11 @@ export async function listAllIssues(opts: ListIssuesOpts = {}): Promise<IssueWit
|
|
|
374
380
|
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
|
|
375
381
|
args.push(like, like, like, like);
|
|
376
382
|
}
|
|
383
|
+
const label = (opts.label ?? "").trim();
|
|
384
|
+
if (label) {
|
|
385
|
+
sql += " AND EXISTS (SELECT 1 FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)";
|
|
386
|
+
args.push(label);
|
|
387
|
+
}
|
|
377
388
|
sql += " ORDER BY i.updated_at DESC LIMIT ?";
|
|
378
389
|
args.push(limit);
|
|
379
390
|
return await getDB().all<IssueWithMeta>(sql, args);
|
|
@@ -644,6 +655,43 @@ export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
|
|
|
644
655
|
);
|
|
645
656
|
}
|
|
646
657
|
|
|
658
|
+
export async function listLabelsForIssues(issueIds: number[]): Promise<Map<number, LabelRow[]>> {
|
|
659
|
+
const result = new Map<number, LabelRow[]>();
|
|
660
|
+
if (issueIds.length === 0) return result;
|
|
661
|
+
const placeholders = issueIds.map(() => "?").join(",");
|
|
662
|
+
const rows = await getDB().all<{
|
|
663
|
+
issue_id: number;
|
|
664
|
+
label_id: number;
|
|
665
|
+
name: string;
|
|
666
|
+
color: string;
|
|
667
|
+
description: string | null;
|
|
668
|
+
exclusive: number;
|
|
669
|
+
is_archived: number;
|
|
670
|
+
}>(
|
|
671
|
+
`SELECT il.issue_id, l.id AS label_id, l.name, l.color, l.description, l.exclusive, l.is_archived
|
|
672
|
+
FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id
|
|
673
|
+
WHERE il.issue_id IN (${placeholders})
|
|
674
|
+
ORDER BY l.name`,
|
|
675
|
+
issueIds
|
|
676
|
+
);
|
|
677
|
+
for (const r of rows) {
|
|
678
|
+
const issueId = r.issue_id;
|
|
679
|
+
const label: LabelRow = {
|
|
680
|
+
id: r.label_id,
|
|
681
|
+
project_id: 0,
|
|
682
|
+
name: r.name,
|
|
683
|
+
color: r.color,
|
|
684
|
+
description: r.description ?? "",
|
|
685
|
+
exclusive: r.exclusive,
|
|
686
|
+
is_archived: r.is_archived,
|
|
687
|
+
};
|
|
688
|
+
const list = result.get(issueId);
|
|
689
|
+
if (list) list.push(label);
|
|
690
|
+
else result.set(issueId, [label]);
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
|
|
647
695
|
export async function getLabel(projectId: number, id: number): Promise<LabelRow | null> {
|
|
648
696
|
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id])) ?? null;
|
|
649
697
|
}
|
package/src/views/issueList.ts
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
|
|
2
|
-
import { getProject, listIssues, type IssueWithMeta } from "../store";
|
|
2
|
+
import { getProject, listIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
|
|
3
3
|
import { relTime } from "../render/components";
|
|
4
4
|
|
|
5
5
|
export const LIST_PAGE_SIZE = 50;
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
|
|
8
|
+
const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
|
|
9
|
+
return `<a class="issue-label" href="${escapeAttr(href)}" style="background:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</a>`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function issueRow(
|
|
13
|
+
it: IssueWithMeta,
|
|
14
|
+
owner: string,
|
|
15
|
+
repo: string,
|
|
16
|
+
q: string,
|
|
17
|
+
state: string,
|
|
18
|
+
labels: LabelRow[],
|
|
19
|
+
): string {
|
|
8
20
|
const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${it.number}`;
|
|
9
21
|
const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
|
|
22
|
+
const chips = labels.length
|
|
23
|
+
? `<div class="row-labels">${labels.map((l) => labelChip(l, owner, repo, state)).join("")}</div>`
|
|
24
|
+
: "";
|
|
10
25
|
return `<a class="row" href="${escapeAttr(href)}">
|
|
11
26
|
<div class="row-title">${title}</div>
|
|
27
|
+
${chips}
|
|
12
28
|
<div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
|
|
13
29
|
</a>`;
|
|
14
30
|
}
|
|
@@ -18,13 +34,15 @@ export async function buildIssueList(
|
|
|
18
34
|
repo: string,
|
|
19
35
|
state: "open" | "closed" | "all",
|
|
20
36
|
writesEnabled: boolean,
|
|
21
|
-
q: string
|
|
37
|
+
q: string,
|
|
38
|
+
label: string = "",
|
|
22
39
|
): Promise<string> {
|
|
23
40
|
const project = await getProject(owner, repo);
|
|
24
41
|
if (!project) {
|
|
25
42
|
return notFoundProject(owner, repo);
|
|
26
43
|
}
|
|
27
|
-
const issues = await listIssues(project.id, { state, q, limit: LIST_PAGE_SIZE });
|
|
44
|
+
const issues = await listIssues(project.id, { state, q, label, limit: LIST_PAGE_SIZE });
|
|
45
|
+
const labelMap = await listLabelsForIssues(issues.map((it) => it.id));
|
|
28
46
|
const matchesFirst = q
|
|
29
47
|
? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
|
|
30
48
|
: issues;
|
|
@@ -32,11 +50,15 @@ export async function buildIssueList(
|
|
|
32
50
|
const closedActive = state === "closed" ? " active" : "";
|
|
33
51
|
const allActive = state === "all" ? " active" : "";
|
|
34
52
|
const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
|
|
53
|
+
const labelParam = label ? `&label=${encodeURIComponent(label)}` : "";
|
|
54
|
+
const labelFilterHint = label
|
|
55
|
+
? `<div class="label-filter">标签: <strong>${escapeHtml(label)}</strong> <a href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}${qParam}" class="label-clear">✕</a></div>`
|
|
56
|
+
: "";
|
|
35
57
|
const newBtn = writesEnabled
|
|
36
58
|
? `<a class="new-btn" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/new">+ 新建</a>`
|
|
37
59
|
: "";
|
|
38
60
|
const rows = matchesFirst.length
|
|
39
|
-
? matchesFirst.map((it) => issueRow(it, owner, repo, q)).join("")
|
|
61
|
+
? matchesFirst.map((it) => issueRow(it, owner, repo, q, state, labelMap.get(it.id) ?? [])).join("")
|
|
40
62
|
: `<div class="empty">暂无工单</div>`;
|
|
41
63
|
const searchVal = escapeAttr(q);
|
|
42
64
|
return `<!doctype html>
|
|
@@ -57,6 +79,13 @@ export async function buildIssueList(
|
|
|
57
79
|
.row-title{font-weight:500;overflow-wrap:anywhere}
|
|
58
80
|
.row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
|
|
59
81
|
.empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
|
|
82
|
+
.row-labels{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.2rem}
|
|
83
|
+
.issue-label{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:500;color:#fff;text-decoration:none;line-height:18px;white-space:nowrap}
|
|
84
|
+
.issue-label:hover{opacity:.85;text-decoration:none}
|
|
85
|
+
.label-filter{font-size:13px;color:var(--text-muted);margin-bottom:.4rem}
|
|
86
|
+
.label-filter strong{color:var(--text)}
|
|
87
|
+
.label-clear{color:var(--text-muted);text-decoration:none;margin-left:.3rem}
|
|
88
|
+
.label-clear:hover{color:var(--text)}
|
|
60
89
|
</style></head><body>
|
|
61
90
|
<header class="topbar">
|
|
62
91
|
<a href="/" style="color:var(--header-text)">🏠</a>
|
|
@@ -71,11 +100,12 @@ ${tabNavHTML("issues")}
|
|
|
71
100
|
<button type="submit" class="new-btn" style="background:var(--bg-muted);color:var(--text)">搜索</button>
|
|
72
101
|
</form>
|
|
73
102
|
<div class="tabs-sub">
|
|
74
|
-
<a class="tab${openActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open${qParam}">Open</a>
|
|
75
|
-
<a class="tab${closedActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed${qParam}">Closed</a>
|
|
76
|
-
<a class="tab${allActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all${qParam}">All</a>
|
|
103
|
+
<a class="tab${openActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open${qParam}${labelParam}">Open</a>
|
|
104
|
+
<a class="tab${closedActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed${qParam}${labelParam}">Closed</a>
|
|
105
|
+
<a class="tab${allActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all${qParam}${labelParam}">All</a>
|
|
77
106
|
${newBtn}
|
|
78
107
|
</div>
|
|
108
|
+
${labelFilterHint}
|
|
79
109
|
<div class="rows">${rows}</div>
|
|
80
110
|
</main>
|
|
81
111
|
</body></html>`;
|
package/src/views/issues.ts
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
|
|
2
|
-
import { listAllIssues, type IssueWithMeta } from "../store";
|
|
2
|
+
import { listAllIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
|
|
3
3
|
import { relTime } from "../render/components";
|
|
4
4
|
|
|
5
5
|
export const FEED_PAGE_SIZE = 50;
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
|
|
8
|
+
const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
|
|
9
|
+
return `<a class="issue-label" href="${escapeAttr(href)}" style="background:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</a>`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function issueRow(it: IssueWithMeta, q: string, state: string, labels: LabelRow[]): string {
|
|
8
13
|
const href = `/${encodeURIComponent(it.project_owner)}/${encodeURIComponent(it.project_name)}/issues/${it.number}`;
|
|
9
14
|
const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
|
|
10
15
|
const projectStr = `${escapeHtml(it.project_owner)}<span style="opacity:.55">/</span>${escapeHtml(it.project_name)}`;
|
|
16
|
+
const chips = labels.length
|
|
17
|
+
? `<div class="row-labels">${labels.map((l) => labelChip(l, it.project_owner, it.project_name, state)).join("")}</div>`
|
|
18
|
+
: "";
|
|
11
19
|
return `<a class="row" href="${escapeAttr(href)}">
|
|
12
20
|
<div class="row-title">${title}</div>
|
|
21
|
+
${chips}
|
|
13
22
|
<div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
|
|
14
23
|
</a>`;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export async function buildIssuesFeed(
|
|
18
27
|
state: "open" | "closed" | "all",
|
|
19
|
-
q: string
|
|
28
|
+
q: string,
|
|
29
|
+
label: string = "",
|
|
20
30
|
): Promise<string> {
|
|
21
|
-
const issues = await listAllIssues({ state, q, limit: FEED_PAGE_SIZE });
|
|
31
|
+
const issues = await listAllIssues({ state, q, label, limit: FEED_PAGE_SIZE });
|
|
32
|
+
const labelMap = await listLabelsForIssues(issues.map((it) => it.id));
|
|
22
33
|
const matchesFirst = q
|
|
23
34
|
? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
|
|
24
35
|
: issues;
|
|
@@ -26,8 +37,11 @@ export async function buildIssuesFeed(
|
|
|
26
37
|
const closedActive = state === "closed" ? " active" : "";
|
|
27
38
|
const allActive = state === "all" ? " active" : "";
|
|
28
39
|
const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
|
|
40
|
+
const labelFilterHint = label
|
|
41
|
+
? `<div class="label-filter">标签: <strong>${escapeHtml(label)}</strong></div>`
|
|
42
|
+
: "";
|
|
29
43
|
const rows = matchesFirst.length
|
|
30
|
-
? matchesFirst.map((it) => issueRow(it, q)).join("")
|
|
44
|
+
? matchesFirst.map((it) => issueRow(it, q, state, labelMap.get(it.id) ?? [])).join("")
|
|
31
45
|
: `<div class="empty">暂无工单</div>`;
|
|
32
46
|
return `<!doctype html>
|
|
33
47
|
<html lang="zh"><head><meta charset="utf-8">
|
|
@@ -46,6 +60,11 @@ export async function buildIssuesFeed(
|
|
|
46
60
|
.row-title{font-weight:500;overflow-wrap:anywhere}
|
|
47
61
|
.row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
|
|
48
62
|
.empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
|
|
63
|
+
.row-labels{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.2rem}
|
|
64
|
+
.issue-label{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:500;color:#fff;text-decoration:none;line-height:18px;white-space:nowrap}
|
|
65
|
+
.issue-label:hover{opacity:.85;text-decoration:none}
|
|
66
|
+
.label-filter{font-size:13px;color:var(--text-muted);margin-bottom:.4rem}
|
|
67
|
+
.label-filter strong{color:var(--text)}
|
|
49
68
|
</style></head><body>
|
|
50
69
|
<header class="topbar"><span style="font-weight:600">📦 ework</span></header>
|
|
51
70
|
${tabNavHTML("issues")}
|
|
@@ -60,6 +79,7 @@ ${tabNavHTML("issues")}
|
|
|
60
79
|
<a class="tab${closedActive}" href="/issues?state=closed${qParam}">Closed</a>
|
|
61
80
|
<a class="tab${allActive}" href="/issues?state=all${qParam}">All</a>
|
|
62
81
|
</div>
|
|
82
|
+
${labelFilterHint}
|
|
63
83
|
<div class="rows">${rows}</div>
|
|
64
84
|
</main>
|
|
65
85
|
</body></html>`;
|