draftlink 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 +674 -0
- package/README.md +92 -0
- package/bin/draftlink +393 -0
- package/migrations/0000_baseline.sql +51 -0
- package/package.json +45 -0
- package/skills/draftlink/SKILL.md +54 -0
- package/src/assets.ts +4 -0
- package/src/drafts.ts +209 -0
- package/src/index.ts +572 -0
- package/src/ui.ts +696 -0
- package/src/util.ts +119 -0
- package/tsconfig.json +15 -0
- package/wrangler.jsonc +15 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { esc, dateLabel } from "./util";
|
|
2
|
+
import { STATUS_ORDER, type Access, type DraftRow, type Status, type VersionRow } from "./drafts";
|
|
3
|
+
|
|
4
|
+
export interface DashboardOptions {
|
|
5
|
+
q: string;
|
|
6
|
+
filter: string;
|
|
7
|
+
project: string;
|
|
8
|
+
projects: string[];
|
|
9
|
+
mine: DraftRow[];
|
|
10
|
+
shared: DraftRow[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_STYLE: Record<string, { chip: string; icon: string }> = {
|
|
14
|
+
active: { chip: "bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300", icon: "○" },
|
|
15
|
+
done: { chip: "bg-emerald-200 dark:bg-emerald-900 text-emerald-800 dark:text-emerald-300", icon: "✓" },
|
|
16
|
+
archived: { chip: "bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 line-through", icon: "⌛" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const THEME_SCRIPT = `<script src="/theme.js"></script>`;
|
|
20
|
+
|
|
21
|
+
const BASE_STYLE = `<style>
|
|
22
|
+
@view-transition{navigation:auto}
|
|
23
|
+
html{background:#fafafa}
|
|
24
|
+
html.dark{background:#09090b}
|
|
25
|
+
@media (prefers-reduced-motion:reduce){::view-transition-group(*),::view-transition-image-pair(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}
|
|
26
|
+
</style>`;
|
|
27
|
+
|
|
28
|
+
const SPECULATION_RULES = `<script type="speculationrules">{"prerender":[{"source":"document","where":{"href_matches":"\\\\/*"},"eagerness":"moderate"}]}</script>`;
|
|
29
|
+
|
|
30
|
+
const LOCAL_TIME_SCRIPT = `<script>
|
|
31
|
+
(() => {
|
|
32
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
33
|
+
const formatter = new Intl.DateTimeFormat("en-GB", {
|
|
34
|
+
day: "2-digit",
|
|
35
|
+
month: "short",
|
|
36
|
+
year: "numeric",
|
|
37
|
+
hour: "2-digit",
|
|
38
|
+
minute: "2-digit",
|
|
39
|
+
hour12: false,
|
|
40
|
+
timeZone,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function formatLocalTime(date) {
|
|
44
|
+
const parts = Object.fromEntries(formatter.formatToParts(date).map(({ type, value }) => [type, value]));
|
|
45
|
+
return parts.day + "/" + parts.month + "/" + parts.year + " " + parts.hour + ":" + parts.minute;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
window.dlFormatLocalTimes = (root = document) => {
|
|
49
|
+
root.querySelectorAll("time[data-local-time]").forEach((element) => {
|
|
50
|
+
const value = element.getAttribute("datetime");
|
|
51
|
+
if (!value) return;
|
|
52
|
+
const date = new Date(value);
|
|
53
|
+
if (Number.isNaN(date.getTime())) return;
|
|
54
|
+
element.textContent = formatLocalTime(date);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
document.addEventListener("click", (e) => {
|
|
59
|
+
document.querySelectorAll("details[open]").forEach((d) => {
|
|
60
|
+
if (!d.contains(e.target)) d.removeAttribute("open");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (document.readyState === "loading") {
|
|
65
|
+
document.addEventListener("DOMContentLoaded", () => window.dlFormatLocalTimes());
|
|
66
|
+
} else {
|
|
67
|
+
window.dlFormatLocalTimes();
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
</script>`;
|
|
71
|
+
|
|
72
|
+
function localTime(ms: number): string {
|
|
73
|
+
const iso = new Date(ms).toISOString();
|
|
74
|
+
return `<time datetime="${iso}" data-local-time>${dateLabel(ms)}</time>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const REPORTER_SCRIPT = `<script>
|
|
78
|
+
(function () {
|
|
79
|
+
function post() {
|
|
80
|
+
var el = document.documentElement;
|
|
81
|
+
var h = Math.max(el.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
82
|
+
parent.postMessage({ type: "dl-size", h: h }, "*");
|
|
83
|
+
}
|
|
84
|
+
["load", "resize"].forEach(function (ev) { addEventListener(ev, post); });
|
|
85
|
+
document.addEventListener("DOMContentLoaded", post);
|
|
86
|
+
if (window.ResizeObserver) new ResizeObserver(post).observe(document.documentElement);
|
|
87
|
+
post();
|
|
88
|
+
})();
|
|
89
|
+
</script>`;
|
|
90
|
+
|
|
91
|
+
// scrollHeight can miss bottom body margins on some layouts; switch to iframe-resizer if that ever shows.
|
|
92
|
+
export function injectEmbed(body: string): string {
|
|
93
|
+
let out = body;
|
|
94
|
+
const injectIntoHead = (snippet: string) => {
|
|
95
|
+
if (/<head[^>]*>/i.test(out)) out = out.replace(/<head[^>]*>/i, (m) => m + snippet);
|
|
96
|
+
else out = snippet + out;
|
|
97
|
+
};
|
|
98
|
+
if (!/\/theme\.js/.test(out)) injectIntoHead(`<script src="/theme.js"></script>`);
|
|
99
|
+
if (!/tailwind\.config/.test(out)) injectIntoHead(`<script>tailwind.config = { darkMode: 'class' }</script>`);
|
|
100
|
+
if (!/cdn\.tailwindcss\.com/.test(out)) injectIntoHead(`<script src="https://cdn.tailwindcss.com"></script>`);
|
|
101
|
+
if (!out.includes("dl-size")) injectIntoHead(REPORTER_SCRIPT);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function themeButton(): string {
|
|
106
|
+
return `<button id="themeToggle" onclick="dlToggleTheme()" aria-label="Toggle dark mode" class="rounded-md border border-zinc-300 dark:border-zinc-600 px-2 py-1 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">◐</button>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function layout(opts: { title: string; body: string; user?: { login: string; admin?: boolean; pending?: number } | null; base: string; bare?: boolean }): string {
|
|
110
|
+
const head = `<meta charset="utf-8">
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
112
|
+
<title>${esc(opts.title)}</title>
|
|
113
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
114
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
115
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
116
|
+
<script>tailwind.config = { darkMode: 'class' }</script>
|
|
117
|
+
${THEME_SCRIPT}
|
|
118
|
+
${BASE_STYLE}
|
|
119
|
+
${SPECULATION_RULES}
|
|
120
|
+
${LOCAL_TIME_SCRIPT}`;
|
|
121
|
+
if (opts.bare) {
|
|
122
|
+
return `<!doctype html>
|
|
123
|
+
<html lang="en">
|
|
124
|
+
<head>
|
|
125
|
+
${head}
|
|
126
|
+
</head>
|
|
127
|
+
<body class="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 min-h-screen">
|
|
128
|
+
${opts.body}
|
|
129
|
+
</body>
|
|
130
|
+
</html>`;
|
|
131
|
+
}
|
|
132
|
+
const pendingBadge = opts.user?.pending
|
|
133
|
+
? ` <span class="rounded-full bg-amber-200 px-1.5 py-0.5 text-xs text-amber-800 dark:bg-amber-900 dark:text-amber-300">${opts.user.pending}</span>`
|
|
134
|
+
: "";
|
|
135
|
+
const adminLink = opts.user?.admin ? `<a href="/admin" class="text-sm font-medium hover:underline">Admin${pendingBadge}</a>` : "";
|
|
136
|
+
const nav = opts.user
|
|
137
|
+
? `<nav class="flex flex-wrap items-center justify-end gap-x-3 gap-y-2">
|
|
138
|
+
<a href="/dashboard" class="text-sm font-medium hover:underline">Dashboard</a>
|
|
139
|
+
${adminLink}
|
|
140
|
+
<a href="/install" class="text-sm font-medium hover:underline">Install</a>
|
|
141
|
+
<a href="/keys" class="text-sm font-medium hover:underline">API keys</a>
|
|
142
|
+
<form method="post" action="/logout" class="inline"><button class="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">Sign out (@${esc(opts.user.login)})</button></form>
|
|
143
|
+
${themeButton()}
|
|
144
|
+
</nav>`
|
|
145
|
+
: `<div>${themeButton()}</div>`;
|
|
146
|
+
return `<!doctype html>
|
|
147
|
+
<html lang="en">
|
|
148
|
+
<head>
|
|
149
|
+
${head}
|
|
150
|
+
</head>
|
|
151
|
+
<body class="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 min-h-screen">
|
|
152
|
+
<div class="max-w-5xl mx-auto px-4 py-6">
|
|
153
|
+
<header class="flex flex-col items-start gap-4 mb-8 sm:flex-row sm:items-center sm:justify-between">
|
|
154
|
+
<a href="/" class="flex items-center gap-1.5 text-lg font-bold tracking-tight"><img src="/logo.png" alt="" class="h-6 w-6">draftlink</a>
|
|
155
|
+
${nav}
|
|
156
|
+
</header>
|
|
157
|
+
${opts.body}
|
|
158
|
+
</div>
|
|
159
|
+
</body>
|
|
160
|
+
</html>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function loginPage(base: string, opts: { configured: boolean; error?: string }): string {
|
|
164
|
+
const error = opts.error ? `<p class="mb-4 rounded-md bg-red-100 dark:bg-red-950 text-red-800 dark:text-red-300 px-4 py-2 text-sm">${esc(opts.error)}</p>` : "";
|
|
165
|
+
const button = opts.configured
|
|
166
|
+
? `<a href="/auth/github" class="inline-flex items-center gap-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 font-medium hover:opacity-90">
|
|
167
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
168
|
+
Sign in with GitHub
|
|
169
|
+
</a>`
|
|
170
|
+
: `<p class="rounded-md bg-amber-100 dark:bg-amber-950 text-amber-900 dark:text-amber-300 px-4 py-3 text-sm">Server not configured: set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET and SESSION_SECRET secrets first.</p>`;
|
|
171
|
+
return layout({
|
|
172
|
+
base,
|
|
173
|
+
title: "draftlink — sign in",
|
|
174
|
+
body: `
|
|
175
|
+
<div class="max-w-sm mx-auto mt-24 text-center">
|
|
176
|
+
<img src="/banner.png" alt="DraftLink — from idea to impact" class="mb-6 w-full rounded-xl shadow-md">
|
|
177
|
+
<h1 class="text-2xl font-bold mb-2">draftlink</h1>
|
|
178
|
+
<p class="text-sm text-zinc-500 mb-6">Publish HTML drafts, share them, move on.</p>
|
|
179
|
+
${error}
|
|
180
|
+
${button}
|
|
181
|
+
</div>`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function draftCard(d: DraftRow, isOwner: boolean): string {
|
|
186
|
+
const st = STATUS_STYLE[d.status] ?? STATUS_STYLE.active;
|
|
187
|
+
const nextIdx = (STATUS_ORDER.indexOf(d.status as Status) + 1) % STATUS_ORDER.length;
|
|
188
|
+
const next = STATUS_ORDER[nextIdx];
|
|
189
|
+
return `
|
|
190
|
+
<li class="flex items-start gap-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-3">
|
|
191
|
+
<form method="post" action="/drafts/${esc(d.id)}/status" title="Mark as ${next}">
|
|
192
|
+
<input type="hidden" name="status" value="${next}">
|
|
193
|
+
<button class="mt-0.5 w-6 h-6 rounded-full border flex items-center justify-center text-xs ${st.chip}" aria-label="Status: ${esc(d.status)}, click to set ${next}">${st.icon}</button>
|
|
194
|
+
</form>
|
|
195
|
+
<div class="min-w-0 flex-1">
|
|
196
|
+
<a href="/d/${esc(d.id)}" class="font-medium hover:underline block truncate">${esc(d.title)}</a>
|
|
197
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-zinc-500">
|
|
198
|
+
<span class="rounded bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5">${esc(d.project)}</span>
|
|
199
|
+
${d.is_public ? `<span class="rounded bg-sky-100 dark:bg-sky-950 text-sky-700 dark:text-sky-400 px-1.5 py-0.5">public</span>` : ""}
|
|
200
|
+
<span title="created">${localTime(d.created_at)}</span>
|
|
201
|
+
${d.updated_at - d.created_at > 60_000 ? `<span title="updated">(edited)</span>` : ""}
|
|
202
|
+
${!isOwner && d.owner_login ? `<span>shared by @${esc(d.owner_login)}</span>` : ""}
|
|
203
|
+
<a href="/drafts/${esc(d.id)}/edit" class="hover:underline">edit</a>
|
|
204
|
+
${isOwner ? `<a href="/drafts/${esc(d.id)}/share" class="hover:underline">share</a>` : ""}
|
|
205
|
+
${
|
|
206
|
+
isOwner
|
|
207
|
+
? `<form method="post" action="/drafts/${esc(d.id)}/delete" class="inline" onsubmit="return confirm('Delete this draft?')"><button class="hover:underline text-red-600 dark:text-red-400">delete</button></form>`
|
|
208
|
+
: ""
|
|
209
|
+
}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</li>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function groupList(title: string, drafts: DraftRow[], isOwner: boolean): string {
|
|
216
|
+
if (drafts.length === 0) return "";
|
|
217
|
+
const byMonth = new Map<string, DraftRow[]>();
|
|
218
|
+
for (const d of drafts) {
|
|
219
|
+
const key = new Date(d.created_at).toISOString().slice(0, 7);
|
|
220
|
+
const arr = byMonth.get(key);
|
|
221
|
+
if (arr) arr.push(d);
|
|
222
|
+
else byMonth.set(key, [d]);
|
|
223
|
+
}
|
|
224
|
+
const sections = [...byMonth.entries()]
|
|
225
|
+
.map(([month, rows]) => {
|
|
226
|
+
const label = new Date(`${month}-01T00:00:00Z`).toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
|
|
227
|
+
return `<section class="mb-6">
|
|
228
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-zinc-400 mb-2">${esc(label)} · ${rows.length}</h3>
|
|
229
|
+
<ul class="space-y-2">${rows.map((d) => draftCard(d, isOwner)).join("")}</ul>
|
|
230
|
+
</section>`;
|
|
231
|
+
})
|
|
232
|
+
.join("");
|
|
233
|
+
return `<h2 class="text-lg font-semibold mb-3">${title}</h2>${sections}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function dashboardHref(opts: DashboardOptions, overrides: { filter?: string; project?: string } = {}): string {
|
|
237
|
+
const params = new URLSearchParams();
|
|
238
|
+
if (opts.q) params.set("q", opts.q);
|
|
239
|
+
params.set("filter", overrides.filter ?? opts.filter);
|
|
240
|
+
const project = overrides.project ?? opts.project;
|
|
241
|
+
if (project) params.set("project", project);
|
|
242
|
+
return `/dashboard?${params.toString()}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function dashboardFilters(opts: DashboardOptions): string {
|
|
246
|
+
const filters = ["all", "active", "done", "archived"];
|
|
247
|
+
const chips = filters
|
|
248
|
+
.map((f) => {
|
|
249
|
+
const cls =
|
|
250
|
+
f === opts.filter
|
|
251
|
+
? "bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900"
|
|
252
|
+
: "border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800";
|
|
253
|
+
return `<a href="${dashboardHref(opts, { filter: f })}" data-filter="${f}" class="rounded-full px-3 py-1 text-xs ${cls}">${f}</a>`;
|
|
254
|
+
})
|
|
255
|
+
.join("");
|
|
256
|
+
const projectChips = ["", ...opts.projects]
|
|
257
|
+
.map((project) => {
|
|
258
|
+
const selected = project === opts.project;
|
|
259
|
+
const cls = selected
|
|
260
|
+
? "bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900"
|
|
261
|
+
: "border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800";
|
|
262
|
+
const label = project || "All projects";
|
|
263
|
+
return `<a href="${dashboardHref(opts, { project })}" data-project="${esc(project)}" class="rounded-full px-3 py-1 text-xs whitespace-nowrap ${cls}">${esc(label)}</a>`;
|
|
264
|
+
})
|
|
265
|
+
.join("");
|
|
266
|
+
return `<div id="filterControls" class="space-y-2" aria-label="Draft filters">
|
|
267
|
+
<div id="statusFilters" class="flex flex-wrap gap-1.5" aria-label="Filter by status">${chips}</div>
|
|
268
|
+
<div id="projectFilters" class="flex flex-wrap gap-1.5" aria-label="Filter by project">${projectChips}</div>
|
|
269
|
+
</div>`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function dashboardResults(opts: DashboardOptions): string {
|
|
273
|
+
const hasResults = opts.mine.length > 0 || opts.shared.length > 0;
|
|
274
|
+
const empty = !hasResults
|
|
275
|
+
? opts.q || opts.project || opts.filter !== "all"
|
|
276
|
+
? `<p class="text-sm text-zinc-500">No drafts match these filters.</p>`
|
|
277
|
+
: `<div class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-6 text-center">
|
|
278
|
+
<p class="text-sm font-medium mb-1">Nothing here yet</p>
|
|
279
|
+
<p class="text-sm text-zinc-500 mb-3">Install the CLI and the agent skill to publish drafts straight from your coding agent.</p>
|
|
280
|
+
<a href="/install" class="inline-block rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 text-sm font-medium hover:opacity-90">Go to install →</a>
|
|
281
|
+
</div>`
|
|
282
|
+
: "";
|
|
283
|
+
return `<div id="draftResults" aria-live="polite">
|
|
284
|
+
${empty}
|
|
285
|
+
${groupList("My drafts", opts.mine, true)}
|
|
286
|
+
${groupList("Shared with me", opts.shared, false)}
|
|
287
|
+
</div>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function dashboardFragment(opts: DashboardOptions): string {
|
|
291
|
+
return `${dashboardFilters(opts)}${dashboardResults(opts)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function dashboardPage(base: string, user: { login: string }, opts: DashboardOptions): string {
|
|
295
|
+
const newForm = `
|
|
296
|
+
<details class="mb-8">
|
|
297
|
+
<summary class="cursor-pointer text-sm font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">+ New draft</summary>
|
|
298
|
+
<form method="post" action="/drafts" enctype="multipart/form-data" class="mt-3 space-y-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
|
299
|
+
<div class="flex flex-col gap-2 sm:flex-row">
|
|
300
|
+
<input name="title" placeholder="Title" required class="flex-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm">
|
|
301
|
+
<input name="project" placeholder="project" class="w-full rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm sm:w-40">
|
|
302
|
+
</div>
|
|
303
|
+
<textarea name="body" rows="10" placeholder="<html>… (or attach a file below)" class="w-full rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm font-mono"></textarea>
|
|
304
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
305
|
+
<input type="file" name="file" accept=".html,.htm,text/html" class="text-sm">
|
|
306
|
+
<button class="w-full rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 text-sm font-medium hover:opacity-90 sm:w-auto">Publish</button>
|
|
307
|
+
</div>
|
|
308
|
+
</form>
|
|
309
|
+
</details>`;
|
|
310
|
+
const body = `
|
|
311
|
+
<div class="mb-6 space-y-3">
|
|
312
|
+
<form id="dashboardSearch" method="get" action="/dashboard" class="flex flex-col gap-2 sm:flex-row">
|
|
313
|
+
<input id="draftSearch" type="search" name="q" value="${esc(opts.q)}" placeholder="Search title, project, content…" autocomplete="off" class="min-w-0 flex-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm">
|
|
314
|
+
<input type="hidden" name="filter" value="${esc(opts.filter)}">
|
|
315
|
+
<input type="hidden" name="project" value="${esc(opts.project)}">
|
|
316
|
+
<button class="w-full rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 sm:w-auto">Search</button>
|
|
317
|
+
</form>
|
|
318
|
+
${dashboardFilters(opts)}
|
|
319
|
+
</div>
|
|
320
|
+
${newForm}
|
|
321
|
+
${dashboardResults(opts)}
|
|
322
|
+
${DASHBOARD_SCRIPT}`;
|
|
323
|
+
return layout({ base, title: "draftlink", user, body });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const DASHBOARD_SCRIPT = `<script>
|
|
327
|
+
(() => {
|
|
328
|
+
const search = document.querySelector("#draftSearch");
|
|
329
|
+
const form = document.querySelector("#dashboardSearch");
|
|
330
|
+
const controls = document.querySelector("#filterControls");
|
|
331
|
+
let pending;
|
|
332
|
+
let debounce;
|
|
333
|
+
|
|
334
|
+
function state() {
|
|
335
|
+
const params = new URL(window.location.href).searchParams;
|
|
336
|
+
return {
|
|
337
|
+
q: params.get("q") || "",
|
|
338
|
+
filter: params.get("filter") || "all",
|
|
339
|
+
project: params.get("project") || "",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function updateUrl(next, mode) {
|
|
344
|
+
const url = new URL(window.location.href);
|
|
345
|
+
if (next.q) url.searchParams.set("q", next.q); else url.searchParams.delete("q");
|
|
346
|
+
if (next.filter && next.filter !== "all") url.searchParams.set("filter", next.filter); else url.searchParams.delete("filter");
|
|
347
|
+
if (next.project) url.searchParams.set("project", next.project); else url.searchParams.delete("project");
|
|
348
|
+
window.history[mode](null, "", url);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function syncSearch() {
|
|
352
|
+
if (search) search.value = state().q;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function refresh() {
|
|
356
|
+
if (pending) pending.abort();
|
|
357
|
+
pending = new AbortController();
|
|
358
|
+
const url = new URL(window.location.href);
|
|
359
|
+
url.searchParams.set("fragment", "1");
|
|
360
|
+
try {
|
|
361
|
+
const response = await fetch(url, { headers: { Accept: "text/html" }, signal: pending.signal });
|
|
362
|
+
if (!response.ok) return;
|
|
363
|
+
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
|
|
364
|
+
for (const id of ["statusFilters", "projectFilters", "draftResults"]) {
|
|
365
|
+
const current = document.querySelector("#" + id);
|
|
366
|
+
const next = doc.querySelector("#" + id);
|
|
367
|
+
if (current && next && current.innerHTML !== next.innerHTML) current.replaceWith(next);
|
|
368
|
+
}
|
|
369
|
+
window.dlFormatLocalTimes?.();
|
|
370
|
+
syncSearch();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!(error instanceof DOMException && error.name === "AbortError")) console.error("draft refresh failed", error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
controls?.addEventListener("click", (event) => {
|
|
377
|
+
const target = event.target;
|
|
378
|
+
const link = target instanceof Element ? target.closest("a[data-filter], a[data-project]") : null;
|
|
379
|
+
if (!link) return;
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
const next = state();
|
|
382
|
+
if (link.dataset.filter) next.filter = link.dataset.filter;
|
|
383
|
+
if (link.dataset.project !== undefined) next.project = link.dataset.project;
|
|
384
|
+
updateUrl(next, "pushState");
|
|
385
|
+
void refresh();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
search?.addEventListener("input", () => {
|
|
389
|
+
const next = state();
|
|
390
|
+
next.q = search.value;
|
|
391
|
+
updateUrl(next, "replaceState");
|
|
392
|
+
clearTimeout(debounce);
|
|
393
|
+
debounce = setTimeout(() => void refresh(), 150);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
form?.addEventListener("submit", (event) => {
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
const next = state();
|
|
399
|
+
next.q = search?.value || "";
|
|
400
|
+
updateUrl(next, "pushState");
|
|
401
|
+
void refresh();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
window.addEventListener("popstate", () => {
|
|
405
|
+
syncSearch();
|
|
406
|
+
void refresh();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
window.setInterval(() => {
|
|
410
|
+
if (document.visibilityState === "visible") void refresh();
|
|
411
|
+
}, 10000);
|
|
412
|
+
})();
|
|
413
|
+
</script>`;
|
|
414
|
+
|
|
415
|
+
export interface ShellOptions {
|
|
416
|
+
access: Access;
|
|
417
|
+
versions: VersionRow[];
|
|
418
|
+
versionId: number | null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function draftShellHeader(opts: ShellOptions): string {
|
|
422
|
+
const d = opts.access.draft;
|
|
423
|
+
const st = STATUS_STYLE[d.status] ?? STATUS_STYLE.active;
|
|
424
|
+
const nextIdx = (STATUS_ORDER.indexOf(d.status as Status) + 1) % STATUS_ORDER.length;
|
|
425
|
+
const next = STATUS_ORDER[nextIdx];
|
|
426
|
+
const live = opts.versionId === null;
|
|
427
|
+
const viewedIdx = live ? -1 : opts.versions.findIndex((v) => v.id === opts.versionId);
|
|
428
|
+
const chip = opts.access.canEdit
|
|
429
|
+
? `<form method="post" action="/drafts/${esc(d.id)}/status" class="shrink-0" title="Mark as ${next}">
|
|
430
|
+
<input type="hidden" name="status" value="${next}">
|
|
431
|
+
<button class="flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${st.chip}" aria-label="Status: ${esc(d.status)}, click to set ${next}">${st.icon} ${esc(d.status)}</button>
|
|
432
|
+
</form>`
|
|
433
|
+
: `<span class="rounded-full px-2.5 py-0.5 text-xs font-medium ${st.chip}">${st.icon} ${esc(d.status)}</span>`;
|
|
434
|
+
const dropdown = opts.versions.length
|
|
435
|
+
? `<details class="relative shrink-0">
|
|
436
|
+
<summary class="cursor-pointer list-none rounded-md border border-zinc-300 px-2 py-1 text-xs hover:bg-zinc-100 dark:border-zinc-600 dark:hover:bg-zinc-800">${viewedIdx >= 0 ? `v${opts.versions.length - viewedIdx}` : "history"}<span class="ml-1 text-zinc-400">▾</span></summary>
|
|
437
|
+
<div class="absolute right-0 z-50 mt-1 max-h-72 w-60 overflow-y-auto rounded-lg border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900">
|
|
438
|
+
<a href="/d/${esc(d.id)}" class="block rounded px-2 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 ${live ? "font-semibold" : ""}">live (current)</a>
|
|
439
|
+
${opts.versions
|
|
440
|
+
.map(
|
|
441
|
+
(v, i) =>
|
|
442
|
+
`<a href="/d/${esc(d.id)}?v=${v.id}" class="block rounded px-2 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 ${opts.versionId === v.id ? "bg-zinc-100 font-semibold dark:bg-zinc-800" : ""}">v${opts.versions.length - i} · ${localTime(v.created_at)}</a>`
|
|
443
|
+
)
|
|
444
|
+
.join("")}
|
|
445
|
+
</div>
|
|
446
|
+
</details>`
|
|
447
|
+
: "";
|
|
448
|
+
return `<header id="dl-hdr" data-updated-at="${d.updated_at}" data-versions="${opts.versions.length}" data-viewing="${live ? 0 : 1}" data-meta="/d/${esc(d.id)}?meta=1" class="sticky top-0 z-50 flex items-center gap-2 border-b border-zinc-200 bg-zinc-50/95 px-3 py-2 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95">
|
|
449
|
+
<a href="/" class="flex shrink-0 items-center gap-1.5 text-sm font-bold tracking-tight"><img src="/logo.png" alt="" class="h-4 w-4">draftlink</a>
|
|
450
|
+
<span class="min-w-0 flex-1 truncate text-sm font-medium text-zinc-500">${esc(d.title)}</span>
|
|
451
|
+
${chip}
|
|
452
|
+
${dropdown}
|
|
453
|
+
${themeButton()}
|
|
454
|
+
</header>`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function draftShellBanner(opts: ShellOptions): string {
|
|
458
|
+
if (opts.versionId === null) return "";
|
|
459
|
+
const d = opts.access.draft;
|
|
460
|
+
const idx = opts.versions.findIndex((v) => v.id === opts.versionId);
|
|
461
|
+
const label = idx >= 0 ? `v${opts.versions.length - idx} from ${localTime(opts.versions[idx].created_at)}` : "a saved version";
|
|
462
|
+
const restore = opts.access.isOwner
|
|
463
|
+
? `<form method="post" action="/drafts/${esc(d.id)}/restore" class="inline"><input type="hidden" name="v" value="${opts.versionId}"><button class="font-medium underline">Restore this version</button></form>`
|
|
464
|
+
: "";
|
|
465
|
+
return `<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300">
|
|
466
|
+
<span class="flex-1">Viewing ${label} — it won't live-update.</span>
|
|
467
|
+
<a href="/d/${esc(d.id)}" class="font-medium underline">Back to live</a>
|
|
468
|
+
${restore}
|
|
469
|
+
</div>`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const SHELL_SCRIPT = `<script>
|
|
473
|
+
(() => {
|
|
474
|
+
const frame = document.getElementById("dl-frame");
|
|
475
|
+
let hdr = document.getElementById("dl-hdr");
|
|
476
|
+
const viewing = hdr.dataset.viewing === "1";
|
|
477
|
+
let busy = false;
|
|
478
|
+
|
|
479
|
+
addEventListener("message", (e) => {
|
|
480
|
+
if (e.source !== frame.contentWindow) return;
|
|
481
|
+
if (e.data && e.data.type === "dl-size" && typeof e.data.h === "number" && e.data.h > 0) {
|
|
482
|
+
frame.style.height = Math.ceil(e.data.h) + "px";
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
setInterval(async () => {
|
|
487
|
+
if (busy || document.visibilityState !== "visible") return;
|
|
488
|
+
busy = true;
|
|
489
|
+
try {
|
|
490
|
+
const res = await fetch(hdr.dataset.meta, { headers: { Accept: "text/html" } });
|
|
491
|
+
if (!res.ok) return;
|
|
492
|
+
const doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
|
493
|
+
const next = doc.getElementById("dl-hdr");
|
|
494
|
+
if (!next) return;
|
|
495
|
+
const newVersions = Number(next.dataset.versions) !== Number(hdr.dataset.versions);
|
|
496
|
+
if (newVersions || next.dataset.updatedAt !== hdr.dataset.updatedAt) {
|
|
497
|
+
hdr.replaceWith(next);
|
|
498
|
+
hdr = next;
|
|
499
|
+
}
|
|
500
|
+
if (newVersions && !viewing) {
|
|
501
|
+
const u = new URL(frame.src);
|
|
502
|
+
u.searchParams.set("t", Date.now());
|
|
503
|
+
frame.src = u.pathname + u.search;
|
|
504
|
+
}
|
|
505
|
+
} catch {}
|
|
506
|
+
finally { busy = false; }
|
|
507
|
+
}, 10000);
|
|
508
|
+
})();
|
|
509
|
+
</script>`;
|
|
510
|
+
|
|
511
|
+
export function draftShellPage(opts: ShellOptions): string {
|
|
512
|
+
const d = opts.access.draft;
|
|
513
|
+
const embedUrl = `/d/${esc(d.id)}?embed=1${opts.versionId ? `&v=${opts.versionId}` : ""}`;
|
|
514
|
+
const body = `
|
|
515
|
+
${draftShellHeader(opts)}
|
|
516
|
+
${draftShellBanner(opts)}
|
|
517
|
+
<iframe id="dl-frame" src="${embedUrl}" sandbox="allow-scripts allow-popups" title="${esc(d.title)}" class="block w-full border-0" style="height:100vh"></iframe>
|
|
518
|
+
${SHELL_SCRIPT}`;
|
|
519
|
+
return layout({ base: "", title: d.title, bare: true, body });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function editPage(base: string, user: { login: string }, d: DraftRow & { body: string }): string {
|
|
523
|
+
const body = `
|
|
524
|
+
<h1 class="text-xl font-bold mb-4">Edit draft</h1>
|
|
525
|
+
<form method="post" action="/drafts/${esc(d.id)}/edit" enctype="multipart/form-data" class="space-y-3 max-w-3xl">
|
|
526
|
+
<div class="flex flex-col gap-2 sm:flex-row">
|
|
527
|
+
<input name="title" value="${esc(d.title)}" required class="flex-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm">
|
|
528
|
+
<input name="project" value="${esc(d.project)}" class="w-full rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm sm:w-40">
|
|
529
|
+
</div>
|
|
530
|
+
<textarea name="body" rows="22" class="w-full rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm font-mono">${esc(d.body)}</textarea>
|
|
531
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
532
|
+
<label class="text-sm text-zinc-500">replace with file <input type="file" name="file" accept=".html,.htm,text/html" class="ml-1 max-w-full"></label>
|
|
533
|
+
<div class="flex flex-wrap gap-2">
|
|
534
|
+
<a href="/d/${esc(d.id)}" class="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">View live ↗</a>
|
|
535
|
+
<button class="rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 text-sm font-medium hover:opacity-90">Save</button>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
</form>`;
|
|
539
|
+
return layout({ base, title: `edit — ${d.title}`, user, body });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function sharePage(base: string, user: { login: string }, d: DraftRow, grants: { github_id: number; login: string }[], error?: string): string {
|
|
543
|
+
const err = error ? `<p class="mb-3 rounded-md bg-red-100 dark:bg-red-950 text-red-800 dark:text-red-300 px-3 py-2 text-sm">${esc(error)}</p>` : "";
|
|
544
|
+
const rows = grants.length
|
|
545
|
+
? grants
|
|
546
|
+
.map(
|
|
547
|
+
(g) => `<li class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2">
|
|
548
|
+
<span class="text-sm font-medium">@${esc(g.login)}</span>
|
|
549
|
+
<form method="post" action="/drafts/${esc(d.id)}/revoke"><input type="hidden" name="gid" value="${g.github_id}"><button class="text-sm text-red-600 dark:text-red-400 hover:underline">revoke</button></form>
|
|
550
|
+
</li>`
|
|
551
|
+
)
|
|
552
|
+
.join("")
|
|
553
|
+
: `<li class="text-sm text-zinc-500">Not shared with anyone yet.</li>`;
|
|
554
|
+
const visibility = d.is_public
|
|
555
|
+
? `<form method="post" action="/drafts/${esc(d.id)}/visibility" class="flex flex-wrap items-center gap-3 mb-6 rounded-lg border border-sky-200 dark:border-sky-900 bg-sky-50 dark:bg-sky-950 px-3 py-2 max-w-md">
|
|
556
|
+
<span class="text-sm text-sky-700 dark:text-sky-400 flex-1">Public — anyone with the link can read</span>
|
|
557
|
+
<input type="hidden" name="value" value="private">
|
|
558
|
+
<button class="text-sm font-medium hover:underline">Make private</button>
|
|
559
|
+
</form>`
|
|
560
|
+
: `<form method="post" action="/drafts/${esc(d.id)}/visibility" class="flex flex-wrap items-center gap-3 mb-6 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 max-w-md">
|
|
561
|
+
<span class="text-sm text-zinc-500 flex-1">Private — only you and people below</span>
|
|
562
|
+
<input type="hidden" name="value" value="public">
|
|
563
|
+
<button class="text-sm font-medium hover:underline">Make public (read-only link)</button>
|
|
564
|
+
</form>`;
|
|
565
|
+
const body = `
|
|
566
|
+
<h1 class="text-xl font-bold mb-1">Share “${esc(d.title)}”</h1>
|
|
567
|
+
<p class="text-sm text-zinc-500 mb-6">Grants get read/write. Only you can delete, re-share, or change visibility.</p>
|
|
568
|
+
${visibility}
|
|
569
|
+
${err}
|
|
570
|
+
<form method="post" action="/drafts/${esc(d.id)}/share" class="flex flex-col gap-2 mb-6 max-w-md sm:flex-row">
|
|
571
|
+
<input name="login" placeholder="github handle e.g. octocat" required class="flex-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm">
|
|
572
|
+
<button class="w-full rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 text-sm font-medium hover:opacity-90 sm:w-auto">Grant read/write</button>
|
|
573
|
+
</form>
|
|
574
|
+
<ul class="space-y-2 max-w-md">${rows}</ul>`;
|
|
575
|
+
return layout({ base, title: `share — ${d.title}`, user, body });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function keysPage(base: string, user: { login: string }, keys: { id: number; prefix: string; label: string; created_at: number; last_used_at: number | null }[], newKey?: string): string {
|
|
579
|
+
const banner = newKey
|
|
580
|
+
? `<div class="mb-6 rounded-lg border border-emerald-300 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950 p-4">
|
|
581
|
+
<p class="text-sm font-medium mb-2">New API key (copy now, shown once):</p>
|
|
582
|
+
<code class="block break-all rounded bg-white dark:bg-zinc-900 px-3 py-2 text-sm select-all">${esc(newKey)}</code>
|
|
583
|
+
</div>`
|
|
584
|
+
: "";
|
|
585
|
+
const rows = keys.length
|
|
586
|
+
? keys
|
|
587
|
+
.map(
|
|
588
|
+
(k) => `<li class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2">
|
|
589
|
+
<div>
|
|
590
|
+
<code class="text-sm">${esc(k.prefix)}…</code>
|
|
591
|
+
<span class="ml-2 text-xs text-zinc-500">${esc(k.label)} · created ${localTime(k.created_at)} · ${k.last_used_at ? `last used ${localTime(k.last_used_at)}` : "never used"}</span>
|
|
592
|
+
</div>
|
|
593
|
+
<form method="post" action="/keys/revoke"><input type="hidden" name="id" value="${k.id}"><button class="text-sm text-red-600 dark:text-red-400 hover:underline">revoke</button></form>
|
|
594
|
+
</li>`
|
|
595
|
+
)
|
|
596
|
+
.join("")
|
|
597
|
+
: `<li class="text-sm text-zinc-500">No API keys yet.</li>`;
|
|
598
|
+
const usage = `<pre class="mt-6 overflow-x-auto rounded-lg bg-zinc-900 text-zinc-100 dark:bg-black p-4 text-xs leading-relaxed">draftlink auth login # paste this key once; CLI stores it (agents never see it)
|
|
599
|
+
|
|
600
|
+
curl -X POST "${esc(base)}/api/drafts?title=My%20plan&project=myrepo" \\
|
|
601
|
+
-H "Authorization: Bearer dl_..." \\
|
|
602
|
+
-H "Content-Type: text/html" \\
|
|
603
|
+
--data-binary @plan.html
|
|
604
|
+
|
|
605
|
+
curl -X PUT "${esc(base)}/api/drafts/<id>?status=done" -H "Authorization: Bearer dl_…" -H "Content-Type: text/html" --data-binary @v2.html
|
|
606
|
+
curl -X DELETE "${esc(base)}/api/drafts/<id>" -H "Authorization: Bearer dl_…"
|
|
607
|
+
curl "${esc(base)}/api/drafts?q=search" -H "Authorization: Bearer dl_…"</pre>`;
|
|
608
|
+
const body = `
|
|
609
|
+
<h1 class="text-xl font-bold mb-1">API keys</h1>
|
|
610
|
+
<p class="text-sm text-zinc-500 mb-6">Keys act as you, full read/write. Revoke leaked ones here.</p>
|
|
611
|
+
${banner}
|
|
612
|
+
<form method="post" action="/keys" class="flex flex-col gap-2 mb-6 max-w-md sm:flex-row">
|
|
613
|
+
<input name="label" placeholder="label e.g. laptop-cli" class="flex-1 rounded-md border border-zinc-300 dark:border-zinc-700 bg-transparent px-3 py-2 text-sm">
|
|
614
|
+
<button class="w-full rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-4 py-2 text-sm font-medium hover:opacity-90 sm:w-auto">Create key</button>
|
|
615
|
+
</form>
|
|
616
|
+
<ul class="space-y-2">${rows}</ul>
|
|
617
|
+
${usage}`;
|
|
618
|
+
return layout({ base, title: "API keys", user, body });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const COPY_SCRIPT = `<script>
|
|
622
|
+
document.querySelectorAll("[data-copy]").forEach((btn) => {
|
|
623
|
+
btn.addEventListener("click", () => {
|
|
624
|
+
const text = btn.parentElement.querySelector("pre").textContent;
|
|
625
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
626
|
+
btn.textContent = "copied!";
|
|
627
|
+
setTimeout(() => (btn.textContent = "copy"), 1500);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
</script>`;
|
|
632
|
+
|
|
633
|
+
export function installPage(base: string, user: { login: string }): string {
|
|
634
|
+
const cmdBlock = (code: string) => `<div class="relative">
|
|
635
|
+
<pre class="overflow-x-auto rounded bg-zinc-900 text-zinc-100 dark:bg-black p-3 text-xs">${esc(code)}</pre>
|
|
636
|
+
<button data-copy class="absolute right-2 top-2 rounded border border-zinc-600 bg-zinc-800 px-2 py-0.5 text-[10px] text-zinc-300 hover:bg-zinc-700">copy</button>
|
|
637
|
+
</div>`;
|
|
638
|
+
const step = (n: number, title: string, body: string) => `<li class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
|
639
|
+
<p class="font-medium mb-2">${n}. ${title}</p>
|
|
640
|
+
<div class="text-sm text-zinc-500 space-y-2">${body}</div>
|
|
641
|
+
</li>`;
|
|
642
|
+
const body = `
|
|
643
|
+
<h1 class="text-xl font-bold mb-1">Install</h1>
|
|
644
|
+
<p class="text-sm text-zinc-500 mb-6">Same steps on macOS, Linux and Windows. Requires <a href="https://nodejs.org" class="underline">Node 22+</a>.</p>
|
|
645
|
+
<ol class="space-y-3 max-w-2xl">
|
|
646
|
+
${step(1, "Install the CLI", cmdBlock("npm install -g draftlink"))}
|
|
647
|
+
${step(2, "Install the agent skill", `Teaches your coding agents (Claude Code, Codex, …) to publish drafts. The installer asks which harnesses to target.${cmdBlock("npx skills add lm-sousa/draftlink --skill draftlink -g")}`)}
|
|
648
|
+
${step(3, "Log in once — you, in a terminal", `Run it, paste this instance's URL, and paste your API key when asked — create it in step 4 so it's still in your clipboard. Agents only ever call <code>draftlink</code> and never see the token.${cmdBlock("draftlink auth login")}or include the <code>--url</code> argument — handy for scripts and CI:${cmdBlock(`draftlink auth login --url ${base}`)}`)}
|
|
649
|
+
${step(4, "Create an API key", `<a href="/keys" class="font-medium underline">Create a key</a> and copy it — it's shown once. This is the token the CLI asks for in step 3.`)}
|
|
650
|
+
</ol>
|
|
651
|
+
<h2 class="text-lg font-semibold mt-10 mb-2">Staying up to date</h2>
|
|
652
|
+
<div class="max-w-2xl space-y-2 text-sm text-zinc-500">
|
|
653
|
+
<p>The CLI prints a one-line notice when a newer release exists — run it or update any time:</p>
|
|
654
|
+
${cmdBlock("draftlink upgrade\nnpx skills update draftlink -g # refresh the skill too")}
|
|
655
|
+
<p>Windows note: pipe stdin like <code>Get-Content plan.html | draftlink upload -</code>, or pass the file directly.</p>
|
|
656
|
+
</div>
|
|
657
|
+
${COPY_SCRIPT}`;
|
|
658
|
+
return layout({ base, title: "Install", user, body });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function adminPage(base: string, user: { login: string }, users: { id: number; login: string; status: string; is_admin: number; created_at: number }[]): string {
|
|
662
|
+
const STATUS_STYLES: Record<string, string> = {
|
|
663
|
+
approved: "text-emerald-600 dark:text-emerald-400",
|
|
664
|
+
pending: "text-amber-600 dark:text-amber-400",
|
|
665
|
+
banned: "text-red-600 dark:text-red-400",
|
|
666
|
+
};
|
|
667
|
+
const rows = users
|
|
668
|
+
.map((u) => `<li class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2">
|
|
669
|
+
<div>
|
|
670
|
+
<span class="text-sm font-medium">@${esc(u.login)}</span>
|
|
671
|
+
<span class="ml-2 text-xs ${STATUS_STYLES[u.status] ?? "text-zinc-500"}">${esc(u.status)}</span>
|
|
672
|
+
<span class="ml-2 text-xs text-zinc-500">joined ${localTime(u.created_at)}</span>
|
|
673
|
+
</div>
|
|
674
|
+
<div class="flex gap-3">
|
|
675
|
+
${u.is_admin ? `<span class="text-xs font-medium text-sky-600 dark:text-sky-400">admin</span>` : ""}
|
|
676
|
+
${u.status !== "approved" ? `<form method="post" action="/admin/approve"><input type="hidden" name="id" value="${u.id}"><button class="text-sm text-emerald-600 dark:text-emerald-400 hover:underline">approve</button></form>` : ""}
|
|
677
|
+
${u.status !== "banned" ? `<form method="post" action="/admin/ban"><input type="hidden" name="id" value="${u.id}"><button class="text-sm text-red-600 dark:text-red-400 hover:underline">ban</button></form>` : ""}
|
|
678
|
+
${u.is_admin ? `<form method="post" action="/admin/remove-admin"><input type="hidden" name="id" value="${u.id}"><button class="text-sm text-zinc-500 hover:underline" title="Removing the last admin is blocked">remove admin</button></form>` : `<form method="post" action="/admin/make-admin"><input type="hidden" name="id" value="${u.id}"><button class="text-sm text-sky-600 dark:text-sky-400 hover:underline">make admin</button></form>`}
|
|
679
|
+
</div>
|
|
680
|
+
</li>`)
|
|
681
|
+
.join("");
|
|
682
|
+
const body = `
|
|
683
|
+
<h1 class="text-xl font-bold mb-1">Admin — accounts</h1>
|
|
684
|
+
<p class="text-sm text-zinc-500 mb-6">New GitHub signups start as <span class="text-amber-600 dark:text-amber-400">pending</span> and can't sign in until you approve them. Banning blocks dashboard and API access immediately. Removing the last remaining admin is blocked to prevent lockout.</p>
|
|
685
|
+
<ul class="space-y-2">${rows}</ul>`;
|
|
686
|
+
return layout({ base, title: "Admin", user, body });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function errorPage(base: string, status: number, message: string, user?: { login: string } | null): string {
|
|
690
|
+
return layout({
|
|
691
|
+
base,
|
|
692
|
+
title: `${status}`,
|
|
693
|
+
user,
|
|
694
|
+
body: `<div class="max-w-md mx-auto mt-24 text-center"><h1 class="text-3xl font-bold mb-2">${status}</h1><p class="text-zinc-500">${esc(message)}</p></div>`,
|
|
695
|
+
});
|
|
696
|
+
}
|