@mevdragon/vidfarm-devcli 0.1.0 → 0.2.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.
Files changed (63) hide show
  1. package/.env.example +11 -4
  2. package/PLATFORM_SPEC.md +142 -2
  3. package/README.md +165 -16
  4. package/SKILL.developer.md +577 -0
  5. package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
  6. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
  7. package/dist/src/account-pages.js +578 -0
  8. package/dist/src/app.js +887 -66
  9. package/dist/src/cli.js +284 -5
  10. package/dist/src/config.js +24 -4
  11. package/dist/src/db.js +427 -18
  12. package/dist/src/dev-app.js +59 -12
  13. package/dist/src/homepage.js +441 -0
  14. package/dist/src/index.js +12 -7
  15. package/dist/src/lib/crypto.js +14 -0
  16. package/dist/src/lib/template-dna.js +542 -0
  17. package/dist/src/lib/template-style-options.js +49 -0
  18. package/dist/src/registry.js +54 -7
  19. package/dist/src/runtime.js +3 -1
  20. package/dist/src/services/auth.js +69 -5
  21. package/dist/src/services/jobs.js +23 -4
  22. package/dist/src/services/providers.js +74 -12
  23. package/dist/src/services/storage.js +52 -18
  24. package/dist/src/services/template-certification.js +160 -0
  25. package/dist/src/services/template-loader.js +37 -0
  26. package/dist/src/services/template-sources.js +135 -0
  27. package/dist/src/worker.js +19 -7
  28. package/dist/templates/template_0000/src/lib/images.js +242 -0
  29. package/dist/templates/template_0000/src/remotion/Root.js +33 -0
  30. package/dist/templates/template_0000/src/sdk.js +3 -0
  31. package/dist/templates/template_0000/src/style-options.js +51 -0
  32. package/dist/templates/template_0000/src/template-dna.js +9 -0
  33. package/dist/templates/template_0000/src/template.js +1217 -0
  34. package/package.json +9 -1
  35. package/templates/template_0000/README.md +121 -0
  36. package/templates/template_0000/SKILL.md +193 -0
  37. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  38. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  39. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  40. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  41. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  42. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  43. package/templates/template_0000/composition.json +11 -0
  44. package/templates/template_0000/package-lock.json +5137 -0
  45. package/templates/template_0000/package.json +30 -0
  46. package/templates/template_0000/research/preview/.gitkeep +1 -0
  47. package/templates/template_0000/research/source_notes.md +7 -0
  48. package/templates/template_0000/scripts/create-site.mjs +27 -0
  49. package/templates/template_0000/scripts/render-cloud.mjs +72 -0
  50. package/templates/template_0000/src/lib/images.ts +284 -0
  51. package/templates/template_0000/src/remotion/Root.js +33 -0
  52. package/templates/template_0000/src/remotion/Root.tsx +75 -0
  53. package/templates/template_0000/src/remotion/index.tsx +4 -0
  54. package/templates/template_0000/src/sdk.ts +122 -0
  55. package/templates/template_0000/src/style-options.js +51 -0
  56. package/templates/template_0000/src/style-options.ts +60 -0
  57. package/templates/template_0000/src/template-dna.ts +15 -0
  58. package/templates/template_0000/src/template.ts +1747 -0
  59. package/templates/template_0000/template.config.json +26 -0
  60. package/templates/template_0000/tsconfig.json +19 -0
  61. package/dist/templates/template_0000/demo-template.js +0 -196
  62. package/dist/templates/template_0000/remotion/Root.js +0 -66
  63. /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
@@ -0,0 +1,578 @@
1
+ function escapeHtml(value) {
2
+ return value
3
+ .replace(/&/g, "&")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;");
7
+ }
8
+ function escapeAttribute(value) {
9
+ return escapeHtml(value).replace(/'/g, "&#39;");
10
+ }
11
+ function shell(input) {
12
+ return `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
17
+ <title>${escapeHtml(input.title)}</title>
18
+ <style>
19
+ :root {
20
+ --bg: #f4f1e8;
21
+ --panel: rgba(255, 252, 246, 0.92);
22
+ --line: rgba(37, 32, 27, 0.12);
23
+ --ink: #171311;
24
+ --muted: #64584d;
25
+ --accent: #9f3d24;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ body {
29
+ margin: 0;
30
+ min-height: 100vh;
31
+ color: var(--ink);
32
+ font-family: Georgia, "Times New Roman", serif;
33
+ background:
34
+ radial-gradient(circle at top left, rgba(159, 61, 36, 0.14), transparent 24%),
35
+ linear-gradient(180deg, #f8f5ed 0%, #f1ebde 100%);
36
+ }
37
+ main {
38
+ width: min(760px, calc(100vw - 32px));
39
+ margin: 0 auto;
40
+ padding: 32px 0 48px;
41
+ }
42
+ main.settings-main {
43
+ width: min(1040px, calc(100vw - 32px));
44
+ }
45
+ .card {
46
+ background: var(--panel);
47
+ border: 1px solid var(--line);
48
+ padding: 24px;
49
+ }
50
+ .topline {
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: space-between;
54
+ gap: 16px;
55
+ margin-bottom: 20px;
56
+ }
57
+ .eyebrow {
58
+ letter-spacing: 0.18em;
59
+ text-transform: uppercase;
60
+ font-size: 11px;
61
+ color: var(--muted);
62
+ }
63
+ a {
64
+ color: inherit;
65
+ }
66
+ h1, h2 {
67
+ margin: 0;
68
+ font-weight: 400;
69
+ }
70
+ h1 {
71
+ font-size: clamp(2rem, 5vw, 3.7rem);
72
+ line-height: 0.96;
73
+ margin-bottom: 12px;
74
+ }
75
+ h2 {
76
+ font-size: 1.5rem;
77
+ margin-bottom: 14px;
78
+ }
79
+ p {
80
+ margin: 0 0 16px;
81
+ color: var(--muted);
82
+ line-height: 1.6;
83
+ }
84
+ .stack {
85
+ display: grid;
86
+ gap: 20px;
87
+ }
88
+ form, .pane {
89
+ border: 1px solid var(--line);
90
+ padding: 18px;
91
+ background: rgba(255, 255, 255, 0.58);
92
+ }
93
+ .auth-shell {
94
+ width: min(420px, 100%);
95
+ }
96
+ .mode-switch {
97
+ font-size: 0.95rem;
98
+ color: var(--muted);
99
+ }
100
+ .mode-switch a {
101
+ text-decoration: none;
102
+ border-bottom: 1px solid rgba(23, 19, 17, 0.18);
103
+ }
104
+ .mode-switch a:hover {
105
+ border-bottom-color: var(--ink);
106
+ }
107
+ label {
108
+ display: block;
109
+ font-size: 12px;
110
+ letter-spacing: 0.14em;
111
+ text-transform: uppercase;
112
+ color: var(--muted);
113
+ margin-bottom: 8px;
114
+ }
115
+ input {
116
+ width: 100%;
117
+ border: 1px solid var(--line);
118
+ background: rgba(255,255,255,0.84);
119
+ color: var(--ink);
120
+ padding: 12px 14px;
121
+ font: inherit;
122
+ margin-bottom: 14px;
123
+ }
124
+ textarea, select {
125
+ width: 100%;
126
+ border: 1px solid var(--line);
127
+ background: rgba(255,255,255,0.84);
128
+ color: var(--ink);
129
+ padding: 12px 14px;
130
+ font: inherit;
131
+ margin-bottom: 14px;
132
+ }
133
+ textarea {
134
+ min-height: 140px;
135
+ resize: vertical;
136
+ }
137
+ button {
138
+ border: 1px solid var(--ink);
139
+ background: var(--ink);
140
+ color: #f8f5ed;
141
+ padding: 11px 14px;
142
+ font: inherit;
143
+ cursor: pointer;
144
+ }
145
+ button[disabled] {
146
+ cursor: not-allowed;
147
+ opacity: 0.45;
148
+ }
149
+ .secondary {
150
+ background: transparent;
151
+ color: var(--ink);
152
+ }
153
+ .notice {
154
+ border: 1px solid var(--line);
155
+ background: rgba(159, 61, 36, 0.07);
156
+ padding: 14px 16px;
157
+ color: var(--ink);
158
+ }
159
+ .price {
160
+ font-size: 2.4rem;
161
+ line-height: 1;
162
+ margin-bottom: 8px;
163
+ }
164
+ .meta {
165
+ display: grid;
166
+ gap: 10px;
167
+ }
168
+ .meta strong {
169
+ display: inline-block;
170
+ min-width: 120px;
171
+ }
172
+ .settings-grid {
173
+ display: grid;
174
+ gap: 18px;
175
+ }
176
+ .settings-stack {
177
+ display: grid;
178
+ gap: 18px;
179
+ }
180
+ .settings-panel {
181
+ border: 1px solid var(--line);
182
+ background: rgba(255, 255, 255, 0.62);
183
+ padding: 20px;
184
+ }
185
+ .settings-panel h2 {
186
+ margin-bottom: 8px;
187
+ }
188
+ .helper {
189
+ font-size: 0.95rem;
190
+ color: var(--muted);
191
+ }
192
+ .toolbar {
193
+ display: flex;
194
+ flex-wrap: wrap;
195
+ gap: 10px;
196
+ align-items: center;
197
+ }
198
+ .secret-wrap {
199
+ display: grid;
200
+ grid-template-columns: minmax(0, 1fr) auto auto;
201
+ gap: 8px;
202
+ align-items: start;
203
+ }
204
+ .secret-wrap input {
205
+ margin-bottom: 0;
206
+ }
207
+ .field-row {
208
+ display: grid;
209
+ grid-template-columns: repeat(2, minmax(0, 1fr));
210
+ gap: 12px;
211
+ }
212
+ .pill {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ border: 1px solid var(--line);
216
+ padding: 4px 10px;
217
+ font-size: 12px;
218
+ letter-spacing: 0.12em;
219
+ text-transform: uppercase;
220
+ color: var(--muted);
221
+ background: rgba(255,255,255,0.7);
222
+ }
223
+ .key-list, .attachment-list {
224
+ display: grid;
225
+ gap: 12px;
226
+ }
227
+ .key-card, .attachment-card {
228
+ border: 1px solid var(--line);
229
+ background: rgba(255,255,255,0.7);
230
+ padding: 16px;
231
+ }
232
+ .card-head {
233
+ display: flex;
234
+ justify-content: space-between;
235
+ align-items: start;
236
+ gap: 12px;
237
+ margin-bottom: 10px;
238
+ }
239
+ .card-head h3 {
240
+ margin: 0 0 4px;
241
+ font-size: 1rem;
242
+ font-weight: 400;
243
+ }
244
+ .tiny {
245
+ font-size: 0.9rem;
246
+ color: var(--muted);
247
+ }
248
+ .inline-form {
249
+ margin: 0;
250
+ }
251
+ .inline-form button {
252
+ padding: 9px 12px;
253
+ }
254
+ .file-link {
255
+ text-decoration: none;
256
+ border-bottom: 1px solid rgba(23, 19, 17, 0.18);
257
+ }
258
+ .file-link:hover {
259
+ border-bottom-color: var(--ink);
260
+ }
261
+ .empty-state {
262
+ border: 1px dashed var(--line);
263
+ padding: 16px;
264
+ color: var(--muted);
265
+ background: rgba(255,255,255,0.35);
266
+ }
267
+ @media (max-width: 820px) {
268
+ main.settings-main {
269
+ width: min(760px, calc(100vw - 32px));
270
+ }
271
+ }
272
+ @media (max-width: 600px) {
273
+ .field-row, .secret-wrap {
274
+ grid-template-columns: 1fr;
275
+ }
276
+ }
277
+ </style>
278
+ </head>
279
+ <body>
280
+ <main${input.shellClass ? ` class="${escapeHtml(input.shellClass)}"` : ""}>
281
+ <section class="card">
282
+ ${input.body}
283
+ </section>
284
+ </main>
285
+ </body>
286
+ </html>`;
287
+ }
288
+ export function renderLoginPage(input) {
289
+ const mode = input.mode === "password" ? "password" : "otp";
290
+ const email = escapeHtml(input.email ?? "");
291
+ const message = input.message ? `<div class="notice">${escapeHtml(input.message)}</div>` : "";
292
+ const error = input.error ? `<div class="notice">${escapeHtml(input.error)}</div>` : "";
293
+ const switchModeHref = mode === "otp" ? "/login?mode=password" : "/login?mode=otp";
294
+ const switchModeLabel = mode === "otp" ? "Use password instead" : "Use passwordless login";
295
+ const authPane = mode === "password" ? `
296
+ <form method="post" action="/login/password?mode=password">
297
+ <h2>Email + Password</h2>
298
+ <label for="password-email">Email</label>
299
+ <input id="password-email" type="email" name="email" value="${email}" autocomplete="email" required />
300
+ <label for="password">Password</label>
301
+ <input id="password" type="password" name="password" autocomplete="current-password" required />
302
+ <button type="submit">Login</button>
303
+ </form>
304
+ ` : input.otpSent ? `
305
+ <form method="post" action="/login/otp/verify?mode=otp">
306
+ <h2>Email OTP</h2>
307
+ <input type="hidden" name="email" value="${email}" />
308
+ <label for="otp-code">6-digit code</label>
309
+ <input id="otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" required />
310
+ <button type="submit">Verify OTP</button>
311
+ </form>
312
+ ` : `
313
+ <form method="post" action="/login/otp/request?mode=otp">
314
+ <h2>Email OTP</h2>
315
+ <label for="otp-email">Email</label>
316
+ <input id="otp-email" type="email" name="email" value="${email}" autocomplete="email" required />
317
+ <button type="submit">Send OTP</button>
318
+ </form>
319
+ `;
320
+ return shell({
321
+ title: "Login",
322
+ body: `
323
+ <div class="topline">
324
+ <span class="eyebrow">Vidfarm</span>
325
+ <a href="/">Back</a>
326
+ </div>
327
+ <h1>Login</h1>
328
+ <p>Use a paid account to enter the app. There is no free plan.</p>
329
+ <div class="stack">
330
+ ${message}
331
+ ${error}
332
+ <div class="auth-shell">
333
+ ${authPane}
334
+ <p class="mode-switch">${mode === "otp" ? "Prefer a password?" : "Prefer email OTP?"} <a href="${switchModeHref}">${switchModeLabel}</a></p>
335
+ </div>
336
+ </div>
337
+ `
338
+ });
339
+ }
340
+ export function renderPricingPage(input) {
341
+ return shell({
342
+ title: "Pricing",
343
+ body: `
344
+ <div class="topline">
345
+ <span class="eyebrow">Vidfarm</span>
346
+ <a href="/login">Back</a>
347
+ </div>
348
+ <h1>Paid plan required</h1>
349
+ <p>${escapeHtml(input.email)} is not on an active paid plan yet.</p>
350
+ <div class="pane">
351
+ <div class="price">$99<span style="font-size:1rem;">/month</span></div>
352
+ <p>Access is restricted to paid users only. Checkout is not enabled yet.</p>
353
+ <button type="button" disabled>Buy</button>
354
+ </div>
355
+ `
356
+ });
357
+ }
358
+ export function renderSettingsPage(input) {
359
+ const notice = input.notice ? `<div class="notice">${escapeHtml(input.notice)}</div>` : "";
360
+ const error = input.error ? `<div class="notice">${escapeHtml(input.error)}</div>` : "";
361
+ const providerKeys = input.providerKeys.length
362
+ ? input.providerKeys.map((entry) => `
363
+ <article class="key-card">
364
+ <div class="card-head">
365
+ <div>
366
+ <div class="pill">${escapeHtml(entry.provider)}</div>
367
+ <h3>${escapeHtml(entry.label || `${entry.provider} key`)}</h3>
368
+ <div class="tiny">Weight ${entry.weight} · ${escapeHtml(entry.status)} · Added ${escapeHtml(entry.created_at)}</div>
369
+ </div>
370
+ <form class="inline-form" method="post" action="/settings/provider-keys/${encodeURIComponent(entry.id)}/delete">
371
+ <button type="submit" class="secondary">Remove</button>
372
+ </form>
373
+ </div>
374
+ <div class="secret-wrap">
375
+ <input id="provider-secret-${escapeHtml(entry.id)}" type="password" value="${escapeHtml(entry.secret)}" readonly />
376
+ <button type="button" class="secondary" data-toggle-target="provider-secret-${escapeHtml(entry.id)}">Show</button>
377
+ <button type="button" class="secondary" data-copy-target="provider-secret-${escapeHtml(entry.id)}">Copy</button>
378
+ </div>
379
+ </article>
380
+ `).join("")
381
+ : `<div class="empty-state">No AI provider keys saved yet.</div>`;
382
+ const attachments = input.attachments.length
383
+ ? input.attachments.map((entry) => `
384
+ <article class="attachment-card">
385
+ <div class="card-head">
386
+ <div>
387
+ <h3>${escapeHtml(entry.fileName)}</h3>
388
+ <div class="tiny">${escapeHtml(entry.contentType)} · ${formatBytes(entry.sizeBytes)} · ${escapeHtml(entry.createdAt)}</div>
389
+ </div>
390
+ <form class="inline-form" method="post" action="/settings/attachments/${encodeURIComponent(entry.id)}/delete">
391
+ <button type="submit" class="secondary">Remove</button>
392
+ </form>
393
+ </div>
394
+ ${entry.publicUrl ? `<a class="file-link" href="${escapeHtml(entry.publicUrl)}" target="_blank" rel="noreferrer">Open public file</a>` : `<span class="tiny">Stored without a public URL.</span>`}
395
+ </article>
396
+ `).join("")
397
+ : `<div class="empty-state">No attachments uploaded yet.</div>`;
398
+ const developerSkillButton = input.isDeveloper && input.developerSkill ? `
399
+ <button
400
+ type="button"
401
+ class="secondary"
402
+ data-copy-content='${escapeAttribute(input.developerSkill)}'
403
+ data-copy-label="Developer SKILL.md"
404
+ >Developer SKILL.md</button>
405
+ ` : "";
406
+ return shell({
407
+ title: "Settings",
408
+ shellClass: "settings-main",
409
+ body: `
410
+ <div class="topline">
411
+ <span class="eyebrow">Vidfarm</span>
412
+ <a href="/">Home</a>
413
+ </div>
414
+ <h1>Settings</h1>
415
+ <p>Manage your profile, credentials, and reusable source files from one place.</p>
416
+ <div class="stack">
417
+ ${notice}
418
+ ${error}
419
+ <div class="settings-grid">
420
+ <section class="settings-panel">
421
+ <div class="card-head">
422
+ <div>
423
+ <div class="pill">Profile</div>
424
+ <h2>Workspace identity</h2>
425
+ </div>
426
+ <div class="tiny">${input.isPaidPlan ? "Paid plan" : "Inactive plan"}</div>
427
+ </div>
428
+ <form method="post" action="/settings/profile">
429
+ <label for="settings-email">Email</label>
430
+ <input id="settings-email" type="email" value="${escapeHtml(input.email)}" readonly />
431
+ <label for="groupchat-url">Groupchat URL</label>
432
+ <input id="groupchat-url" type="url" name="groupchat_url" value="${escapeHtml(input.groupchatUrl ?? "")}" placeholder="https://chat.example.com/room/..." />
433
+ <label for="about">About</label>
434
+ <textarea id="about" name="about" placeholder="What should your operators or collaborators know about this account?">${escapeHtml(input.about ?? "")}</textarea>
435
+ <label for="flockposter-api-key">Flockposter API key</label>
436
+ <div class="secret-wrap">
437
+ <input id="flockposter-api-key" type="password" name="flockposter_api_key" value="${escapeHtml(input.flockposterApiKey ?? "")}" placeholder="Paste a Flockposter key" />
438
+ <button type="button" class="secondary" data-toggle-target="flockposter-api-key">Show</button>
439
+ <button type="submit">Save profile</button>
440
+ </div>
441
+ </form>
442
+ </section>
443
+ <section class="settings-panel">
444
+ <div class="pill">Vidfarm API</div>
445
+ <h2>Your control-plane key</h2>
446
+ <p class="helper">Use this key with the <code>vidfarm-user-id</code> header pair when calling the API directly.</p>
447
+ <label for="vidfarm-api-key">Current API key</label>
448
+ <div class="secret-wrap">
449
+ <input id="vidfarm-api-key" type="password" value="${escapeHtml(input.vidfarmApiKey)}" readonly />
450
+ <button type="button" class="secondary" data-toggle-target="vidfarm-api-key">Show</button>
451
+ <button type="button" class="secondary" data-copy-target="vidfarm-api-key">Copy</button>
452
+ </div>
453
+ <div class="toolbar" style="margin-top:14px;">
454
+ <button
455
+ type="button"
456
+ class="secondary"
457
+ data-copy-content='${escapeAttribute(input.directorSkill)}'
458
+ data-copy-label="Director SKILL.md"
459
+ >Director SKILL.md</button>
460
+ ${developerSkillButton}
461
+ </div>
462
+ </section>
463
+ <section class="settings-panel">
464
+ <div class="card-head">
465
+ <div>
466
+ <div class="pill">AI Providers</div>
467
+ <h2>Attached inference keys</h2>
468
+ </div>
469
+ </div>
470
+ <p class="helper">These are stored in plain text in SQLite for now, per your current platform requirement.</p>
471
+ <form method="post" action="/settings/provider-keys">
472
+ <div class="field-row">
473
+ <div>
474
+ <label for="provider">Provider type</label>
475
+ <select id="provider" name="provider" required>
476
+ <option value="openai">OpenAI</option>
477
+ <option value="gemini">Gemini</option>
478
+ <option value="openrouter">OpenRouter</option>
479
+ <option value="perplexity">Perplexity</option>
480
+ </select>
481
+ </div>
482
+ <div>
483
+ <label for="provider-label">Label</label>
484
+ <input id="provider-label" name="label" placeholder="Primary key, backup, client pool..." />
485
+ </div>
486
+ </div>
487
+ <div class="field-row">
488
+ <div>
489
+ <label for="provider-secret-new">API key</label>
490
+ <input id="provider-secret-new" type="password" name="secret" required />
491
+ </div>
492
+ <div>
493
+ <label for="provider-weight">Weight</label>
494
+ <input id="provider-weight" type="number" min="1" max="100" name="weight" value="1" required />
495
+ </div>
496
+ </div>
497
+ <div class="toolbar">
498
+ <button type="button" class="secondary" data-toggle-target="provider-secret-new">Show key</button>
499
+ <button type="submit">Add provider key</button>
500
+ </div>
501
+ </form>
502
+ <div class="key-list" style="margin-top:18px;">
503
+ ${providerKeys}
504
+ </div>
505
+ </section>
506
+ <section class="settings-panel">
507
+ <div class="pill">Attachments</div>
508
+ <h2>Shared files</h2>
509
+ <p class="helper">Uploads are stored under your <code>user/*</code> storage path and can be opened via public read URLs.</p>
510
+ <form method="post" action="/settings/attachments" enctype="multipart/form-data">
511
+ <label for="settings-files">Images, video, audio, PDF, Markdown, or text</label>
512
+ <input id="settings-files" type="file" name="files" multiple accept="image/*,video/*,audio/*,.pdf,.md,.txt,text/markdown,text/plain,application/pdf" />
513
+ <button type="submit">Upload files</button>
514
+ </form>
515
+ <div class="attachment-list" style="margin-top:18px;">
516
+ ${attachments}
517
+ </div>
518
+ </section>
519
+ <section class="settings-panel">
520
+ <div class="pill">Session</div>
521
+ <h2>Leave this browser</h2>
522
+ <form method="post" action="/logout">
523
+ <button type="submit" class="secondary">Logout</button>
524
+ </form>
525
+ </section>
526
+ </div>
527
+ </div>
528
+ <script>
529
+ document.querySelectorAll("[data-toggle-target]").forEach((button) => {
530
+ button.addEventListener("click", () => {
531
+ const target = document.getElementById(button.getAttribute("data-toggle-target"));
532
+ if (!target) return;
533
+ const isHidden = target.getAttribute("type") === "password";
534
+ target.setAttribute("type", isHidden ? "text" : "password");
535
+ button.textContent = isHidden ? "Hide" : "Show";
536
+ });
537
+ });
538
+ document.querySelectorAll("[data-copy-target]").forEach((button) => {
539
+ button.addEventListener("click", async () => {
540
+ const target = document.getElementById(button.getAttribute("data-copy-target"));
541
+ if (!target || !("value" in target)) return;
542
+ const original = button.textContent;
543
+ try {
544
+ await navigator.clipboard.writeText(target.value);
545
+ button.textContent = "Copied";
546
+ setTimeout(() => { button.textContent = original; }, 1200);
547
+ } catch {}
548
+ });
549
+ });
550
+ document.querySelectorAll("[data-copy-content]").forEach((button) => {
551
+ button.addEventListener("click", async () => {
552
+ const content = button.getAttribute("data-copy-content") || "";
553
+ if (!content) return;
554
+ const original = button.textContent;
555
+ try {
556
+ await navigator.clipboard.writeText(content);
557
+ button.textContent = "Copied";
558
+ setTimeout(() => { button.textContent = original; }, 1200);
559
+ } catch {}
560
+ });
561
+ });
562
+ </script>
563
+ `
564
+ });
565
+ }
566
+ function formatBytes(value) {
567
+ if (!Number.isFinite(value) || value < 1024) {
568
+ return `${value} B`;
569
+ }
570
+ const units = ["KB", "MB", "GB"];
571
+ let size = value / 1024;
572
+ let unitIndex = 0;
573
+ while (size >= 1024 && unitIndex < units.length - 1) {
574
+ size /= 1024;
575
+ unitIndex += 1;
576
+ }
577
+ return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
578
+ }