@tensorix/metahub 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/cli.js +3755 -2524
- package/dist/cli.js.map +35 -32
- package/dist/core/databases.d.ts.map +1 -1
- package/dist/core/db.d.ts +7 -0
- package/dist/core/db.d.ts.map +1 -1
- package/dist/core/documents.d.ts +11 -0
- package/dist/core/documents.d.ts.map +1 -1
- package/dist/core/errors.d.ts +32 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/history.d.ts +86 -0
- package/dist/core/history.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/properties.d.ts.map +1 -1
- package/dist/core/records.d.ts.map +1 -1
- package/dist/core/resolve.d.ts.map +1 -1
- package/dist/core/schema.d.ts +1 -1
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/sites.d.ts +21 -0
- package/dist/core/sites.d.ts.map +1 -1
- package/dist/core/snapshot.d.ts.map +1 -1
- package/dist/core/sync/client.d.ts.map +1 -1
- package/dist/core/sync/files.d.ts.map +1 -1
- package/dist/core/sync/openapi.d.ts +5 -2
- package/dist/core/sync/openapi.d.ts.map +1 -1
- package/dist/core/sync/pairing.d.ts.map +1 -1
- package/dist/core/sync/peers-routes.d.ts +1 -1
- package/dist/core/sync/peers-routes.d.ts.map +1 -1
- package/dist/core/sync/routes.d.ts +4 -0
- package/dist/core/sync/routes.d.ts.map +1 -1
- package/dist/core/sync/server.d.ts +14 -1
- package/dist/core/sync/server.d.ts.map +1 -1
- package/dist/core/sync/sites-routes.d.ts +1 -1
- package/dist/core/sync/sites-routes.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2547 -1574
- package/dist/index.js.map +25 -23
- package/dist/{core/sync/webui.d.ts → webui/server/assets.d.ts} +1 -5
- package/dist/webui/server/assets.d.ts.map +1 -0
- package/dist/webui/server/routes.d.ts +3 -0
- package/dist/webui/server/routes.d.ts.map +1 -0
- package/dist/webui.js +51 -51
- package/package.json +1 -1
- package/dist/core/sync/webui-routes.d.ts +0 -3
- package/dist/core/sync/webui-routes.d.ts.map +0 -1
- package/dist/core/sync/webui.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -15,6 +15,22 @@ var __export = (target, all) => {
|
|
|
15
15
|
};
|
|
16
16
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
17
|
|
|
18
|
+
// src/core/errors.ts
|
|
19
|
+
function errorCode(e) {
|
|
20
|
+
return e instanceof MhError ? e.code : undefined;
|
|
21
|
+
}
|
|
22
|
+
var MhError;
|
|
23
|
+
var init_errors = __esm(() => {
|
|
24
|
+
MhError = class MhError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = "MhError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
18
34
|
// src/core/paths.ts
|
|
19
35
|
import { homedir } from "os";
|
|
20
36
|
import { join } from "path";
|
|
@@ -342,7 +358,7 @@ var init_crdt = __esm(() => {
|
|
|
342
358
|
},
|
|
343
359
|
documents: {
|
|
344
360
|
table: "documents",
|
|
345
|
-
cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "__deleted"])
|
|
361
|
+
cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "order_key", "__deleted"])
|
|
346
362
|
},
|
|
347
363
|
doc_blocks: {
|
|
348
364
|
table: "doc_blocks",
|
|
@@ -398,6 +414,24 @@ function inferContentType(path) {
|
|
|
398
414
|
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
399
415
|
return MIME[ext] ?? "application/octet-stream";
|
|
400
416
|
}
|
|
417
|
+
function normalizeSiteName(raw) {
|
|
418
|
+
const slug = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
419
|
+
if (!slug)
|
|
420
|
+
throw new MhError("invalid_input", `invalid site name: ${JSON.stringify(raw)}`);
|
|
421
|
+
return slug;
|
|
422
|
+
}
|
|
423
|
+
function normalizeSitePath(raw) {
|
|
424
|
+
const segs = [];
|
|
425
|
+
for (const seg of raw.split("/")) {
|
|
426
|
+
if (seg === "" || seg === ".")
|
|
427
|
+
continue;
|
|
428
|
+
if (seg === "..")
|
|
429
|
+
segs.pop();
|
|
430
|
+
else
|
|
431
|
+
segs.push(seg);
|
|
432
|
+
}
|
|
433
|
+
return segs.join("/");
|
|
434
|
+
}
|
|
401
435
|
function isTextType(contentType) {
|
|
402
436
|
const ct = contentType.toLowerCase();
|
|
403
437
|
return ct.startsWith("text/") || ct.startsWith("application/json") || ct.startsWith("application/manifest+json") || ct.startsWith("application/xml") || ct.startsWith("text/javascript") || ct.startsWith("application/javascript") || ct.startsWith("image/svg+xml");
|
|
@@ -408,10 +442,11 @@ function toBytes(data) {
|
|
|
408
442
|
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
409
443
|
}
|
|
410
444
|
function createSite(db, opts) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
445
|
+
const name = normalizeSiteName(opts.name);
|
|
446
|
+
if (getSiteByName(db, name))
|
|
447
|
+
throw new MhError("conflict", `site name already exists: ${name}`);
|
|
448
|
+
const id = newId("site", name);
|
|
449
|
+
const first = emit(db, "sites", id, "name", name);
|
|
415
450
|
emit(db, "sites", id, "created_hlc", first.hlc);
|
|
416
451
|
if (opts.title !== undefined)
|
|
417
452
|
emit(db, "sites", id, "title", opts.title);
|
|
@@ -421,7 +456,13 @@ function getSite(db, id) {
|
|
|
421
456
|
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE id = ? AND __deleted = 0").get(id);
|
|
422
457
|
}
|
|
423
458
|
function getSiteByName(db, name) {
|
|
424
|
-
|
|
459
|
+
let slug;
|
|
460
|
+
try {
|
|
461
|
+
slug = normalizeSiteName(name);
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE name = ? AND __deleted = 0 ORDER BY created_hlc DESC LIMIT 1").get(slug);
|
|
425
466
|
}
|
|
426
467
|
function listSites(db) {
|
|
427
468
|
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE __deleted = 0 ORDER BY created_hlc").all();
|
|
@@ -430,9 +471,23 @@ function resolveSite(db, ref) {
|
|
|
430
471
|
const byId = ref.startsWith("site_") ? getSite(db, ref) : null;
|
|
431
472
|
const site = byId ?? getSiteByName(db, ref);
|
|
432
473
|
if (!site)
|
|
433
|
-
throw new
|
|
474
|
+
throw new MhError("not_found", `no such site: ${ref}`);
|
|
434
475
|
return site;
|
|
435
476
|
}
|
|
477
|
+
function updateSite(db, id, opts) {
|
|
478
|
+
if (!getSite(db, id))
|
|
479
|
+
throw new MhError("not_found", `no such site: ${id}`);
|
|
480
|
+
if (opts.name !== undefined) {
|
|
481
|
+
const name = normalizeSiteName(opts.name);
|
|
482
|
+
const dup = getSiteByName(db, name);
|
|
483
|
+
if (dup && dup.id !== id)
|
|
484
|
+
throw new MhError("conflict", `site name already exists: ${name}`);
|
|
485
|
+
emit(db, "sites", id, "name", name);
|
|
486
|
+
}
|
|
487
|
+
if (opts.title !== undefined)
|
|
488
|
+
emit(db, "sites", id, "title", opts.title);
|
|
489
|
+
return getSite(db, id);
|
|
490
|
+
}
|
|
436
491
|
function deleteSite(db, id) {
|
|
437
492
|
if (!getSite(db, id))
|
|
438
493
|
return false;
|
|
@@ -447,7 +502,10 @@ function fileIdFor(db, siteId, path) {
|
|
|
447
502
|
return row?.id ?? null;
|
|
448
503
|
}
|
|
449
504
|
async function putFile(db, siteId, path, opts) {
|
|
450
|
-
const
|
|
505
|
+
const cleanPath = normalizeSitePath(path);
|
|
506
|
+
if (!cleanPath)
|
|
507
|
+
throw new MhError("invalid_input", `invalid file path: ${JSON.stringify(path)}`);
|
|
508
|
+
const contentType = opts.contentType ?? inferContentType(cleanPath);
|
|
451
509
|
const bytes = toBytes(opts.data);
|
|
452
510
|
let encoding;
|
|
453
511
|
let content;
|
|
@@ -461,11 +519,11 @@ async function putFile(db, siteId, path, opts) {
|
|
|
461
519
|
encoding = "blob";
|
|
462
520
|
content = (await putBlob(bytes)).hash;
|
|
463
521
|
}
|
|
464
|
-
const existing = fileIdFor(db, siteId,
|
|
465
|
-
const id = existing ?? newId("sf",
|
|
522
|
+
const existing = fileIdFor(db, siteId, cleanPath);
|
|
523
|
+
const id = existing ?? newId("sf", cleanPath);
|
|
466
524
|
if (!existing) {
|
|
467
525
|
const first = emit(db, "site_files", id, "site_id", siteId);
|
|
468
|
-
emit(db, "site_files", id, "path",
|
|
526
|
+
emit(db, "site_files", id, "path", cleanPath);
|
|
469
527
|
emit(db, "site_files", id, "created_hlc", first.hlc);
|
|
470
528
|
}
|
|
471
529
|
emit(db, "site_files", id, "content_type", contentType);
|
|
@@ -478,7 +536,7 @@ function listFiles(db, siteId) {
|
|
|
478
536
|
return db.query("SELECT id, site_id, path, content_type, encoding FROM site_files WHERE site_id = ? AND __deleted = 0 ORDER BY path").all(siteId);
|
|
479
537
|
}
|
|
480
538
|
function deleteFile(db, siteId, path) {
|
|
481
|
-
const id = fileIdFor(db, siteId, path);
|
|
539
|
+
const id = fileIdFor(db, siteId, normalizeSitePath(path));
|
|
482
540
|
if (!id)
|
|
483
541
|
return false;
|
|
484
542
|
const live = db.query("SELECT __deleted AS d FROM site_files WHERE id = ?").get(id);
|
|
@@ -488,7 +546,10 @@ function deleteFile(db, siteId, path) {
|
|
|
488
546
|
return true;
|
|
489
547
|
}
|
|
490
548
|
async function getFileForServe(db, siteId, path) {
|
|
491
|
-
const
|
|
549
|
+
const withIndex = path === "" || path.endsWith("/") ? `${path}index.html` : path;
|
|
550
|
+
const p = normalizeSitePath(withIndex);
|
|
551
|
+
if (!p)
|
|
552
|
+
return null;
|
|
492
553
|
const row = db.query("SELECT content_type, encoding, content FROM site_files WHERE site_id = ? AND path = ? AND __deleted = 0 ORDER BY created_hlc LIMIT 1").get(siteId, p);
|
|
493
554
|
if (!row)
|
|
494
555
|
return null;
|
|
@@ -509,6 +570,7 @@ var init_sites = __esm(() => {
|
|
|
509
570
|
init_ids();
|
|
510
571
|
init_crdt();
|
|
511
572
|
init_cache();
|
|
573
|
+
init_errors();
|
|
512
574
|
INLINE_LIMIT = 256 * 1024;
|
|
513
575
|
MIME = {
|
|
514
576
|
html: "text/html; charset=utf-8",
|
|
@@ -538,1099 +600,299 @@ var init_sites = __esm(() => {
|
|
|
538
600
|
};
|
|
539
601
|
});
|
|
540
602
|
|
|
541
|
-
// src/core/sync/
|
|
542
|
-
var
|
|
543
|
-
__export(
|
|
544
|
-
|
|
545
|
-
setWebuiBundle: () => setWebuiBundle,
|
|
546
|
-
serveWebui: () => serveWebui
|
|
603
|
+
// src/core/sync/sites-serve.ts
|
|
604
|
+
var exports_sites_serve = {};
|
|
605
|
+
__export(exports_sites_serve, {
|
|
606
|
+
serveSite: () => serveSite
|
|
547
607
|
});
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
async function getJs() {
|
|
553
|
-
if (cachedJs != null)
|
|
554
|
-
return cachedJs;
|
|
555
|
-
const prebuilt = Bun.file(fileURLToPath(new URL("./webui.js", import.meta.url)));
|
|
556
|
-
if (await prebuilt.exists()) {
|
|
557
|
-
cachedJs = await prebuilt.text();
|
|
558
|
-
return cachedJs;
|
|
559
|
-
}
|
|
560
|
-
const entry = fileURLToPath(new URL("../../webui/app.tsx", import.meta.url));
|
|
561
|
-
const res = await Bun.build({ entrypoints: [entry], target: "browser", minify: true });
|
|
562
|
-
if (!res.success)
|
|
563
|
-
throw new AggregateError(res.logs, "webui build failed");
|
|
564
|
-
cachedJs = await res.outputs[0].text();
|
|
565
|
-
return cachedJs;
|
|
566
|
-
}
|
|
567
|
-
async function warmWebui() {
|
|
568
|
-
try {
|
|
569
|
-
await getJs();
|
|
570
|
-
} catch {}
|
|
571
|
-
}
|
|
572
|
-
async function serveWebui(req) {
|
|
573
|
-
if (req.method !== "GET")
|
|
608
|
+
async function serveSite(req, ctx) {
|
|
609
|
+
const url2 = new URL(req.url);
|
|
610
|
+
const rest = url2.pathname.slice("/sites/".length);
|
|
611
|
+
if (rest === "")
|
|
574
612
|
return null;
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
console.error("[webui] failed to serve /webui.js \u2014", e);
|
|
586
|
-
return new Response(`webui build failed: ${e}`, { status: 500 });
|
|
587
|
-
}
|
|
613
|
+
const slash = rest.indexOf("/");
|
|
614
|
+
const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
|
|
615
|
+
if (slash === -1)
|
|
616
|
+
return Response.redirect(`${url2.origin}/sites/${name}/`, 301);
|
|
617
|
+
const filePath = decodeURIComponent(rest.slice(slash + 1));
|
|
618
|
+
let siteId;
|
|
619
|
+
try {
|
|
620
|
+
siteId = resolveSite(ctx.db, name).id;
|
|
621
|
+
} catch {
|
|
622
|
+
return new Response("site not found", { status: 404 });
|
|
588
623
|
}
|
|
589
|
-
|
|
624
|
+
const file2 = await getFileForServe(ctx.db, siteId, filePath);
|
|
625
|
+
if (!file2)
|
|
626
|
+
return new Response("not found", { status: 404 });
|
|
627
|
+
return new Response(file2.bytes, { headers: { "content-type": file2.contentType } });
|
|
590
628
|
}
|
|
591
|
-
var
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
<meta charset="utf-8" />
|
|
595
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
596
|
-
<title>Metahub</title>
|
|
597
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
598
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
599
|
-
<!-- Load web fonts without blocking first paint: media="print" keeps the
|
|
600
|
-
stylesheet non-render-blocking, then onload flips it to "all". The page
|
|
601
|
-
paints instantly with the system-font fallbacks baked into --ui/--mono and
|
|
602
|
-
swaps to the web fonts when they arrive (or stays on system fonts offline).
|
|
603
|
-
This is what kept the desktop cold-start white for seconds on slow networks. -->
|
|
604
|
-
<link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
|
605
|
-
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"></noscript>
|
|
606
|
-
<script>try{document.documentElement.dataset.theme=localStorage.getItem('mh-theme')||'system'}catch(e){}</script>
|
|
607
|
-
<style>
|
|
608
|
-
:root {
|
|
609
|
-
--bg:#ffffff; --surface:#fbfbfa; --surface-2:#f1f1ef; --sidebar:#f7f7f5;
|
|
610
|
-
--fg:#2c2c30; --fg-soft:#5b5b62; --muted:#8a8a93; --line:#ebebe8; --line-strong:#dededb;
|
|
611
|
-
--accent:#4a55d6; --accent-fg:#ffffff; --accent-soft:#eef0ff; --danger:#d6473b; --danger-soft:#fdeceb;
|
|
612
|
-
--hover:rgba(45,45,55,.045); --hover-2:rgba(45,45,55,.08);
|
|
613
|
-
--shadow-sm:0 1px 2px rgba(20,20,40,.06),0 1px 1px rgba(20,20,40,.04);
|
|
614
|
-
--shadow-md:0 4px 12px rgba(20,20,40,.08),0 2px 4px rgba(20,20,40,.05);
|
|
615
|
-
--shadow-lg:0 16px 48px rgba(20,20,40,.16),0 4px 12px rgba(20,20,40,.08);
|
|
616
|
-
--radius:8px; --radius-sm:6px;
|
|
617
|
-
--ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
618
|
-
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
|
|
619
|
-
--note-lh:1.6;
|
|
620
|
-
--code-bg:#f8f8f6;
|
|
621
|
-
--hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
|
|
622
|
-
--hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
|
|
623
|
-
}
|
|
624
|
-
/* Dark palette. Applied (a) when explicitly chosen via [data-theme="dark"],
|
|
625
|
-
* or (b) when following the system and the OS reports dark. Light is the
|
|
626
|
-
* default :root above, so [data-theme="light"] needs no extra rule. */
|
|
627
|
-
:root[data-theme="dark"] {
|
|
628
|
-
--bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
|
|
629
|
-
--fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#7d7d86; --line:#2c2c30; --line-strong:#3a3a40;
|
|
630
|
-
--accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
|
|
631
|
-
--hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
|
|
632
|
-
--shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
|
|
633
|
-
--code-bg:#1d1d20;
|
|
634
|
-
--hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
|
|
635
|
-
--hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
|
|
636
|
-
}
|
|
637
|
-
@media (prefers-color-scheme: dark) {
|
|
638
|
-
:root[data-theme="system"], :root:not([data-theme]) {
|
|
639
|
-
--bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
|
|
640
|
-
--fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#7d7d86; --line:#2c2c30; --line-strong:#3a3a40;
|
|
641
|
-
--accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
|
|
642
|
-
--hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
|
|
643
|
-
--shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
|
|
644
|
-
--code-bg:#1d1d20;
|
|
645
|
-
--hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
|
|
646
|
-
--hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
* { box-sizing: border-box; }
|
|
650
|
-
html, body { margin: 0; height: 100%; }
|
|
651
|
-
body { font-family: var(--ui); font-size: 14px; line-height: 1.55; color: var(--fg); background: var(--bg);
|
|
652
|
-
overflow: hidden; text-rendering: auto; }
|
|
653
|
-
button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
|
|
654
|
-
input, textarea { font: inherit; color: inherit; }
|
|
655
|
-
.muted { color: var(--muted); }
|
|
656
|
-
svg { display: block; }
|
|
657
|
-
.ico { width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:1.75; stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
658
|
-
.ico.sm { width:14px; height:14px; }
|
|
659
|
-
::-webkit-scrollbar { width:10px; height:10px; }
|
|
660
|
-
::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:3px solid transparent; background-clip:padding-box; }
|
|
661
|
-
::-webkit-scrollbar-thumb:hover { background:var(--muted); background-clip:padding-box; border:3px solid transparent; }
|
|
662
|
-
|
|
663
|
-
#app { display: flex; height: 100vh; }
|
|
629
|
+
var init_sites_serve = __esm(() => {
|
|
630
|
+
init_sites();
|
|
631
|
+
});
|
|
664
632
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
.sb-resizer { position:absolute; top:0; right:-3px; width:6px; height:100%; cursor:col-resize; z-index:5; }
|
|
669
|
-
.sb-head { display:flex; align-items:center; gap:8px; padding:14px 14px 10px; }
|
|
670
|
-
.brand { display:flex; align-items:center; gap:8px; flex:1; font-weight:700; font-size:15px; letter-spacing:-.01em; }
|
|
671
|
-
.brand .mark { width:24px; height:24px; border-radius:7px; background:linear-gradient(135deg,var(--accent),#8b94ff);
|
|
672
|
-
display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
|
|
673
|
-
.brand .mark svg { width:15px; height:15px; stroke:#fff; }
|
|
674
|
-
.sb-search { margin:0 12px 8px; display:flex; align-items:center; gap:7px; cursor:text;
|
|
675
|
-
background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
|
|
676
|
-
box-shadow:var(--shadow-sm); transition:border-color .15s; }
|
|
677
|
-
.sb-search:focus-within { border-color:var(--accent); }
|
|
678
|
-
.sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
|
|
679
|
-
.sb-search kbd { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
|
|
680
|
-
.sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
|
|
681
|
-
.sb-section { margin-top:12px; }
|
|
682
|
-
.sb-section-head { display:flex; align-items:center; justify-content:space-between;
|
|
683
|
-
font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); padding:4px 8px; }
|
|
684
|
-
.sb-section-head .add { opacity:0; padding:3px; border-radius:5px; color:var(--muted); transition:opacity .12s; }
|
|
685
|
-
.sb-section:hover .add { opacity:1; }
|
|
686
|
-
.sb-section-head .add:hover { background:var(--hover-2); color:var(--fg); }
|
|
687
|
-
.navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; border-radius:var(--radius-sm);
|
|
688
|
-
color:var(--fg-soft); font-size:13.5px; position:relative; white-space:nowrap; transition:background .1s; }
|
|
689
|
-
.navitem:hover { background:var(--hover-2); }
|
|
690
|
-
.navitem.active { background:var(--bg); color:var(--fg); font-weight:600; box-shadow:var(--shadow-sm); }
|
|
691
|
-
.navitem .tw { width:18px; height:18px; display:grid; place-items:center; color:var(--muted); border-radius:4px; flex:none; }
|
|
692
|
-
.navitem .tw:hover { background:var(--hover-2); }
|
|
693
|
-
.navitem .tw svg { width:13px; height:13px; transition:transform .12s; }
|
|
694
|
-
.navitem .tw.open svg { transform:rotate(90deg); }
|
|
695
|
-
.navitem .emoji { width:20px; text-align:center; flex:none; font-size:14px; }
|
|
696
|
-
.navitem .label { flex:1; overflow:hidden; text-overflow:ellipsis; }
|
|
697
|
-
.navitem .acts { display:flex; gap:1px; opacity:0; }
|
|
698
|
-
.navitem:hover .acts { opacity:1; }
|
|
699
|
-
.navitem .acts button { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
700
|
-
.navitem .acts button:hover { background:var(--hover-2); color:var(--fg); }
|
|
701
|
-
.navchildren { margin-left:16px; border-left:1px solid var(--line-strong); padding-left:2px; }
|
|
702
|
-
.navitem.drop-into { box-shadow:inset 0 0 0 2px var(--accent); }
|
|
703
|
-
.navitem.drop-before { box-shadow:inset 0 2px 0 0 var(--accent); }
|
|
704
|
-
.navitem.drop-after { box-shadow:inset 0 -2px 0 0 var(--accent); }
|
|
705
|
-
.dragging { opacity:.35; }
|
|
706
|
-
.sb-footer { flex:none; padding:6px 8px; border-top:1px solid var(--line); }
|
|
707
|
-
.sb-footer .navitem { font-size:13.5px; }
|
|
708
|
-
.nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
|
|
709
|
-
box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
|
|
633
|
+
// src/core/index.ts
|
|
634
|
+
init_errors();
|
|
635
|
+
init_paths();
|
|
710
636
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
.set-section { margin-top:34px; }
|
|
716
|
-
.set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
|
|
717
|
-
.set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
|
|
718
|
-
.set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
|
|
719
|
-
flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
|
|
720
|
-
.set-footer-sep { opacity:.45; }
|
|
721
|
-
.ver-num { font-family:var(--mono); font-size:11px; color:var(--fg-soft); letter-spacing:-.01em; }
|
|
722
|
-
.ver-up { display:inline-flex; align-items:center; gap:6px; }
|
|
723
|
-
.ver-up.err .ver-msg { color:var(--danger); }
|
|
724
|
-
.ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
|
|
725
|
-
color:var(--muted); cursor:pointer; transition:color .12s; }
|
|
726
|
-
.ver-act:hover { color:var(--accent); }
|
|
727
|
-
.ver-act.accent { color:var(--accent); font-weight:600; }
|
|
728
|
-
.ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
|
|
729
|
-
.ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
|
|
730
|
-
.ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
|
|
731
|
-
@keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
|
732
|
-
.theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
|
|
733
|
-
.theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
|
|
734
|
-
text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
|
|
735
|
-
background:var(--surface); color:var(--fg-soft); box-shadow:var(--shadow-sm);
|
|
736
|
-
transition:border-color .14s, background .14s, transform .14s, box-shadow .14s; }
|
|
737
|
-
.theme-card:hover { border-color:var(--muted); transform:translateY(-1px); }
|
|
738
|
-
.theme-card .tc-ico { width:34px; height:34px; display:grid; place-items:center; border-radius:9px;
|
|
739
|
-
background:var(--surface-2); color:var(--fg); }
|
|
740
|
-
.theme-card .tc-ico svg { width:18px; height:18px; }
|
|
741
|
-
.theme-card .tc-name { font-weight:600; font-size:14px; color:var(--fg); }
|
|
742
|
-
.theme-card .tc-desc { font-size:12px; color:var(--muted); line-height:1.45; }
|
|
743
|
-
.theme-card .tc-check { position:absolute; top:10px; right:10px; width:18px; height:18px; border-radius:50%;
|
|
744
|
-
background:var(--accent); color:var(--accent-fg); display:grid; place-items:center; opacity:0; transform:scale(.6); transition:opacity .14s, transform .14s; }
|
|
745
|
-
.theme-card .tc-check svg { width:12px; height:12px; stroke-width:2.6; }
|
|
746
|
-
.theme-card.sel { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset, var(--shadow-sm); }
|
|
747
|
-
.theme-card.sel .tc-ico { background:var(--accent); color:var(--accent-fg); }
|
|
748
|
-
.theme-card.sel .tc-check { opacity:1; transform:scale(1); }
|
|
749
|
-
@media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
|
|
637
|
+
// src/core/db.ts
|
|
638
|
+
init_paths();
|
|
639
|
+
import { Database } from "bun:sqlite";
|
|
640
|
+
import { mkdirSync } from "fs";
|
|
750
641
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
.peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
|
|
758
|
-
.peer-dot.on { background:#3fb950; } .peer-dot.err { background:#f85149; } .peer-dot.off { background:var(--muted); }
|
|
759
|
-
.peer-main { flex:1; min-width:0; }
|
|
760
|
-
.peer-url { font-weight:600; font-size:14px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
761
|
-
.peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
762
|
-
.peer-menu { flex:none; padding:6px; }
|
|
763
|
-
.pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
|
|
764
|
-
text-align:center; padding:16px; border:1px dashed var(--line); border-radius:10px; background:var(--surface); color:var(--fg); }
|
|
642
|
+
// src/core/schema.ts
|
|
643
|
+
var CORE_SCHEMA = `
|
|
644
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
645
|
+
key TEXT PRIMARY KEY,
|
|
646
|
+
value TEXT
|
|
647
|
+
);
|
|
765
648
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
.iconbtn:hover { background:var(--hover-2); color:var(--fg); }
|
|
778
|
-
.content { flex:1; overflow:auto; }
|
|
779
|
-
.btn { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:var(--radius-sm); font-size:13px; font-weight:500; transition:all .12s; white-space:nowrap; }
|
|
780
|
-
.btn-ghost { color:var(--fg-soft); } .btn-ghost:hover { background:var(--hover-2); color:var(--fg); }
|
|
781
|
-
.btn-secondary { background:var(--bg); border:1px solid var(--line-strong); color:var(--fg); box-shadow:var(--shadow-sm); }
|
|
782
|
-
.btn-secondary:hover { background:var(--surface-2); }
|
|
783
|
-
.btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
|
|
784
|
-
.btn-primary:hover { filter:brightness(1.07); }
|
|
785
|
-
.btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
|
|
649
|
+
-- CRDT oplog: one row per field assignment. Source of truth for sync.
|
|
650
|
+
CREATE TABLE IF NOT EXISTS crdt_changes (
|
|
651
|
+
hlc TEXT NOT NULL,
|
|
652
|
+
node_id TEXT NOT NULL,
|
|
653
|
+
dataset TEXT NOT NULL,
|
|
654
|
+
row_id TEXT NOT NULL,
|
|
655
|
+
col TEXT NOT NULL,
|
|
656
|
+
value TEXT,
|
|
657
|
+
PRIMARY KEY (dataset, row_id, col, hlc)
|
|
658
|
+
);
|
|
659
|
+
CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
|
|
786
660
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
661
|
+
-- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
|
|
662
|
+
-- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
|
|
663
|
+
-- a pairing: "token" is the credential the *remote* issued to us (sent as a
|
|
664
|
+
-- Bearer header when we sync to them). See migratePeers in db.ts for the columns
|
|
665
|
+
-- added to legacy databases.
|
|
666
|
+
CREATE TABLE IF NOT EXISTS peers (
|
|
667
|
+
url TEXT PRIMARY KEY,
|
|
668
|
+
pull_cursor INTEGER NOT NULL DEFAULT 0,
|
|
669
|
+
push_cursor INTEGER NOT NULL DEFAULT 0,
|
|
670
|
+
token TEXT,
|
|
671
|
+
label TEXT,
|
|
672
|
+
node_id TEXT,
|
|
673
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
674
|
+
last_sync_at INTEGER,
|
|
675
|
+
last_status TEXT,
|
|
676
|
+
last_error TEXT
|
|
677
|
+
);
|
|
798
678
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
font-family:var(--ui); font-size:15px; line-height:1.7; tab-size:2; }
|
|
809
|
-
.block-wrap { position:relative; }
|
|
810
|
-
.block-wrap.nested { padding-left:24px; }
|
|
811
|
-
/* Subtle indent guide only for true sub-lists; suppressed for code/other
|
|
812
|
-
nested content so code-in-list items don't stack a thicket of lines. */
|
|
813
|
-
.block-wrap.nested:has(> .block.b-bullet, > .block.b-numbered, > .block.b-todo)::before {
|
|
814
|
-
content:""; position:absolute; left:8px; top:2px; bottom:2px; width:1px; background:var(--line); }
|
|
815
|
-
.block { position:relative; display:flex; align-items:flex-start; padding:1px 0; border-radius:4px; }
|
|
816
|
-
.block .gutter { position:absolute; left:-52px; top:2px; display:flex; gap:1px; opacity:0; transition:opacity .12s; }
|
|
817
|
-
.block:hover .gutter, .block.drop-before .gutter, .block.drop-after .gutter, .block.dragging .gutter { opacity:1; }
|
|
818
|
-
.gutter button { width:22px; height:24px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
819
|
-
.gutter button:hover { background:var(--hover-2); color:var(--fg); }
|
|
820
|
-
.gutter .grip { cursor:grab; } .gutter .grip:active { cursor:grabbing; }
|
|
821
|
-
.block.dragging { opacity:.42; background:var(--hover); }
|
|
822
|
-
.block.drop-before::before, .block.drop-after::after { content:""; position:absolute; left:0; right:0; height:2px; background:var(--accent);
|
|
823
|
-
border-radius:2px; pointer-events:none; box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 18%,transparent); }
|
|
824
|
-
.block.drop-before::before { top:-1px; }
|
|
825
|
-
.block.drop-after::after { bottom:-1px; }
|
|
826
|
-
/* multi-block selection: whole-block tint + suppress stray native text highlight while dragging */
|
|
827
|
-
.block.selected { background:var(--accent-soft); }
|
|
828
|
-
.block.selected .editable::selection, .block.selected .editable *::selection { background:transparent; }
|
|
829
|
-
.doc.selecting, .doc.selecting * { user-select:none; -webkit-user-select:none; cursor:default; }
|
|
830
|
-
.block.list-code-host { height:0; min-height:0; padding:0; overflow:visible; }
|
|
831
|
-
.block-wrap:hover > .block.list-code-host .gutter { opacity:1; }
|
|
832
|
-
.block.list-code-host .marker { position:absolute; left:0; top:0; padding-top:4px; z-index:1; }
|
|
833
|
-
/* The list item's host gutter controls the whole unit, so suppress the nested
|
|
834
|
-
code block's own gutter (it would overlap the list marker on the same row). */
|
|
835
|
-
.block.list-code-host + .block-wrap.nested .gutter { display:none; }
|
|
836
|
-
.editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
|
|
837
|
-
.editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
|
|
838
|
-
/* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
|
|
839
|
-
.editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
|
|
840
|
-
.b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
|
|
841
|
-
.b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
|
|
842
|
-
.b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
|
|
843
|
-
.b-h1 .gutter { top:22px; } .b-h2 .gutter { top:14px; } .b-h3 .gutter { top:7px; }
|
|
844
|
-
.b-quote .editable { border-left:3px solid var(--line-strong); padding:1px 0 1px 16px; color:var(--fg-soft); font-style:italic; }
|
|
679
|
+
-- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
|
|
680
|
+
-- presents one of these on /sync; acceptsSyncToken() checks it alongside the
|
|
681
|
+
-- managed master token. Revoke by deleting the row.
|
|
682
|
+
CREATE TABLE IF NOT EXISTS peer_grants (
|
|
683
|
+
token TEXT PRIMARY KEY,
|
|
684
|
+
peer_url TEXT,
|
|
685
|
+
node_id TEXT,
|
|
686
|
+
created_at INTEGER
|
|
687
|
+
);
|
|
845
688
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
.codeblock:hover .code-tools, .code-tools:focus-within { opacity:1; transform:none; pointer-events:auto; }
|
|
856
|
-
.code-lang { position:relative; display:inline-flex; align-items:center; }
|
|
857
|
-
.code-lang select { appearance:none; -webkit-appearance:none; border:0; outline:none; background:none; cursor:pointer;
|
|
858
|
-
font-family:var(--mono); font-size:10.5px; font-weight:500; color:var(--fg-soft); padding:3px 17px 3px 7px; border-radius:5px; }
|
|
859
|
-
.code-lang select:hover { background:var(--hover-2); }
|
|
860
|
-
.code-lang svg { position:absolute; right:3px; width:12px; height:12px; pointer-events:none; color:var(--muted); }
|
|
861
|
-
.code-copy { display:inline-flex; align-items:center; gap:4px; font-size:10.5px; font-weight:500; color:var(--muted);
|
|
862
|
-
padding:3px 7px; border-radius:5px; transition:background .12s,color .12s; }
|
|
863
|
-
.code-copy:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
864
|
-
.code-copy.ok { color:var(--accent); }
|
|
865
|
-
.code-body { display:flex; align-items:stretch; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh); }
|
|
866
|
-
.code-gutter { flex:none; text-align:right; white-space:pre; user-select:none; color:var(--muted); opacity:.55;
|
|
867
|
-
padding:var(--code-pad) 8px var(--code-pad) 12px; border-right:1px solid var(--line); font-variant-numeric:tabular-nums; }
|
|
868
|
-
.code-scroll { position:relative; flex:1; min-width:0; }
|
|
869
|
-
.code-hl, .code-input { margin:0; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh);
|
|
870
|
-
padding:var(--code-pad) 16px; tab-size:2; -moz-tab-size:2; white-space:pre; letter-spacing:0; }
|
|
871
|
-
.code-hl { position:absolute; inset:0; z-index:0; overflow:hidden; pointer-events:none; background:none; color:var(--fg); }
|
|
872
|
-
.code-hl code { display:block; font:inherit; background:none; padding:0; }
|
|
873
|
-
.code-input { display:block; position:relative; z-index:1; width:100%; border:0; outline:none; resize:none;
|
|
874
|
-
overflow-x:auto; overflow-y:hidden; background:transparent; color:transparent; caret-color:var(--fg);
|
|
875
|
-
min-height:calc(var(--code-fs) * var(--code-lh) + var(--code-pad) * 2); }
|
|
876
|
-
.code-input::placeholder { color:var(--muted); }
|
|
689
|
+
-- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
|
|
690
|
+
-- Short-lived and single-use: redeemed during the pairing handshake, then the
|
|
691
|
+
-- two devices exchange durable peer_grants. used/exp gate redemption.
|
|
692
|
+
CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
693
|
+
code TEXT PRIMARY KEY,
|
|
694
|
+
exp INTEGER NOT NULL,
|
|
695
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
696
|
+
created_at INTEGER
|
|
697
|
+
);
|
|
877
698
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
.hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-tag, .hljs-meta, .hljs-link { color:var(--hl-var); }
|
|
886
|
-
.hljs-deletion { color:var(--danger); }
|
|
887
|
-
.hljs-params, .hljs-subst { color:var(--fg); }
|
|
888
|
-
.hljs-emphasis { font-style:italic; }
|
|
889
|
-
.hljs-strong { font-weight:700; }
|
|
890
|
-
.marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
|
|
891
|
-
.b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
|
|
892
|
-
display:flex; align-items:center; justify-content:center; }
|
|
893
|
-
.b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
|
|
894
|
-
.b-divider { padding:10px 0; }
|
|
895
|
-
.b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
|
|
896
|
-
.b-done .editable { color:var(--muted); text-decoration:line-through; }
|
|
897
|
-
.editable b, .editable strong { font-weight:700; }
|
|
898
|
-
.editable code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
|
|
899
|
-
.editable a { color:var(--accent); }
|
|
699
|
+
CREATE TABLE IF NOT EXISTS databases (
|
|
700
|
+
id TEXT PRIMARY KEY,
|
|
701
|
+
name TEXT,
|
|
702
|
+
icon TEXT,
|
|
703
|
+
created_hlc TEXT,
|
|
704
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
705
|
+
);
|
|
900
706
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
table.doc-table tr:last-child td { border-bottom:0; }
|
|
912
|
-
table.doc-table .doc-th { background:var(--surface); }
|
|
913
|
-
.doc-td { outline:none; padding:7px 22px 7px 10px; min-height:20px; line-height:1.55; word-break:break-word; white-space:pre-wrap; }
|
|
914
|
-
.doc-th .doc-td { font-weight:650; color:var(--fg); }
|
|
915
|
-
.doc-td:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
|
|
916
|
-
.doc-td b, .doc-td strong { font-weight:700; }
|
|
917
|
-
.doc-td code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
|
|
918
|
-
.doc-td a { color:var(--accent); }
|
|
919
|
-
/* header cell: column menu + resize handle */
|
|
920
|
-
.doc-col-menu { position:absolute; top:4px; right:4px; width:18px; height:18px; display:grid; place-items:center;
|
|
921
|
-
color:var(--muted); border-radius:4px; opacity:0; transition:opacity .12s,background .12s; }
|
|
922
|
-
.doc-th:hover .doc-col-menu { opacity:1; }
|
|
923
|
-
.doc-col-menu:hover { background:var(--hover-2); color:var(--fg); }
|
|
924
|
-
.doc-col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:3; touch-action:none; }
|
|
925
|
-
.doc-col-resizer:hover, .doc-col-resizer.dragging { background:var(--accent); opacity:.35; }
|
|
926
|
-
/* per-row delete: small handle floating at the first cell on row hover */
|
|
927
|
-
.doc-row-del { position:absolute; top:3px; left:2px; width:18px; height:18px; display:grid; place-items:center;
|
|
928
|
-
color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:5px; box-shadow:var(--shadow-sm);
|
|
929
|
-
opacity:0; transition:opacity .12s; }
|
|
930
|
-
table.doc-table tr:hover .doc-row-del { opacity:1; }
|
|
931
|
-
.doc-row-del:hover { background:var(--danger-soft); color:var(--danger); border-color:var(--danger-soft); }
|
|
932
|
-
/* add-column (right edge) and add-row (bottom edge) buttons */
|
|
933
|
-
.doc-table-addcol { width:22px; margin-left:4px; flex:none; display:grid; place-items:center; color:var(--muted);
|
|
934
|
-
border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
|
|
935
|
-
.doc-table-addrow { width:100%; height:22px; margin-top:4px; display:grid; place-items:center; color:var(--muted);
|
|
936
|
-
border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
|
|
937
|
-
.doc-table-addcol:hover, .doc-table-addrow:hover { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
|
|
707
|
+
CREATE TABLE IF NOT EXISTS properties (
|
|
708
|
+
id TEXT PRIMARY KEY,
|
|
709
|
+
database_id TEXT,
|
|
710
|
+
name TEXT,
|
|
711
|
+
type TEXT,
|
|
712
|
+
config TEXT,
|
|
713
|
+
position REAL,
|
|
714
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
715
|
+
);
|
|
716
|
+
CREATE INDEX IF NOT EXISTS idx_properties_db ON properties(database_id);
|
|
938
717
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
.tbtn:hover { background:var(--hover-2); color:var(--fg); }
|
|
954
|
-
.tbtn.on { color:var(--accent); }
|
|
955
|
-
.tablewrap { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-sm); }
|
|
956
|
-
.tablescroll { overflow-x:auto; }
|
|
957
|
-
/* fixed layout + max-content: each <col> width is authoritative, so the table
|
|
958
|
-
is exactly the sum of its columns and resizing one column never redistributes
|
|
959
|
-
width to its neighbours. min-width:100% fills the container when columns are
|
|
960
|
-
narrow, with the slack absorbed only by the trailing auto (filler) column. */
|
|
961
|
-
table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
|
|
962
|
-
table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
|
|
963
|
-
table.grid th:last-child, table.grid td:last-child { border-right:0; }
|
|
964
|
-
table.grid tbody tr:last-child td { border-bottom:0; }
|
|
965
|
-
thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
|
|
966
|
-
.colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
|
|
967
|
-
.colhead:hover { background:var(--hover); }
|
|
968
|
-
.colhead .ti { color:var(--muted); } .colhead .ti svg { width:14px; height:14px; }
|
|
969
|
-
.colhead .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
970
|
-
.col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
|
|
971
|
-
.col-resizer:hover, .col-resizer.dragging { background:var(--accent); opacity:.35; }
|
|
972
|
-
.addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
|
|
973
|
-
/* trailing filler column: empty canvas to the right of the data, not a real cell */
|
|
974
|
-
table.grid td.filler { border-right:0; border-bottom:0; background:transparent; }
|
|
975
|
-
body.col-resizing { user-select:none; cursor:col-resize; }
|
|
976
|
-
table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
|
|
977
|
-
table.grid .gripcol, table.grid .rowgrip { width:26px; min-width:26px; max-width:26px; text-align:center; }
|
|
978
|
-
td.cell-td { vertical-align:top; }
|
|
979
|
-
.cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
|
|
980
|
-
/* hover/active tint goes on the <td> (always full row height) not the inner
|
|
981
|
-
.cell (only as tall as its content), so a taller sibling row never leaves a
|
|
982
|
-
gap of un-tinted space at the bottom of the hovered cell. */
|
|
983
|
-
td.cell-td { transition:background .1s; }
|
|
984
|
-
td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
|
|
985
|
-
tbody tr { transition:background .08s; }
|
|
986
|
-
tbody tr:hover { background:var(--hover); }
|
|
987
|
-
tbody tr.sel { background:var(--accent-soft); }
|
|
988
|
-
/* cell range selection: per-cell fill; the accent rectangle outline is drawn via inline box-shadow on edge cells. Placed after :hover so it wins on equal specificity. */
|
|
989
|
-
td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
|
|
990
|
-
body.cell-selecting { user-select:none; cursor:cell; }
|
|
991
|
-
body.cell-selecting .cell { cursor:cell; }
|
|
992
|
-
table.grid tr.drop-before td { box-shadow:inset 0 2px 0 0 var(--accent); }
|
|
993
|
-
table.grid tr.drop-after td { box-shadow:inset 0 -2px 0 0 var(--accent); }
|
|
994
|
-
table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
|
|
995
|
-
table.grid th.drop-after { box-shadow:inset -2px 0 0 0 var(--accent); }
|
|
996
|
-
.rowgrip { color:transparent; cursor:grab; vertical-align:middle; touch-action:none; }
|
|
997
|
-
.rowgrip[aria-disabled="true"] { cursor:not-allowed; }
|
|
998
|
-
tbody tr:hover .rowgrip { color:var(--muted); }
|
|
999
|
-
.colhead { touch-action:none; }
|
|
1000
|
-
.drag-source { opacity:.35; }
|
|
1001
|
-
body.table-dragging { user-select:none; cursor:grabbing; }
|
|
1002
|
-
.drag-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; display:flex; align-items:center;
|
|
1003
|
-
padding:7px 11px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; border:1px solid var(--line-strong);
|
|
1004
|
-
border-radius:7px; background:var(--bg); color:var(--fg); box-shadow:var(--shadow-lg); opacity:.94; font-size:13px; }
|
|
1005
|
-
.row-ghost { min-height:34px; }
|
|
1006
|
-
.col-ghost { justify-content:center; font-weight:600; color:var(--fg-soft); background:var(--surface); }
|
|
1007
|
-
.selcell { vertical-align:middle; }
|
|
1008
|
-
.selcell input { width:15px; height:15px; accent-color:var(--accent); opacity:0; cursor:pointer; vertical-align:middle; }
|
|
1009
|
-
tbody tr:hover .selcell input, tbody tr.sel .selcell input, .selcell input:checked, thead .selcell input { opacity:1; }
|
|
1010
|
-
.firstcell { position:relative; width:100%; min-width:0; }
|
|
1011
|
-
/* row actions float over the cell on hover, never reserving column width */
|
|
1012
|
-
/* right:-5px pulls the button out over the cell's 11px right padding so it
|
|
1013
|
-
sits ~6px from the column border instead of ~15px inside it */
|
|
1014
|
-
.rowactions { position:absolute; top:0; bottom:0; right:-5px; display:flex; align-items:flex-start;
|
|
1015
|
-
padding:1px 0 0; opacity:0; pointer-events:none; }
|
|
1016
|
-
tbody tr:hover .rowactions { opacity:1; pointer-events:auto; }
|
|
1017
|
-
/* square icon button \u2014 "open as page" (Notion-style) */
|
|
1018
|
-
.rowopen { width:23px; height:23px; flex:none; display:inline-flex; align-items:center; justify-content:center;
|
|
1019
|
-
border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--fg-soft); box-shadow:var(--shadow-sm); }
|
|
1020
|
-
.rowopen:hover { background:var(--surface-2); color:var(--fg); border-color:var(--line-strong); }
|
|
1021
|
-
.rowopen svg { width:14px; height:14px; }
|
|
1022
|
-
.chip { display:inline-flex; align-items:center; gap:4px; border-radius:5px; padding:2px 8px; font-size:12.5px; font-weight:500; white-space:nowrap;
|
|
1023
|
-
background:color-mix(in srgb,var(--c) 15%,transparent); color:var(--c); }
|
|
1024
|
-
@media (prefers-color-scheme: dark) { .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); } }
|
|
1025
|
-
.addrow { display:flex; align-items:center; gap:7px; padding:9px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-top:1px solid var(--line); }
|
|
1026
|
-
.addrow:hover { background:var(--hover); color:var(--fg-soft); }
|
|
1027
|
-
.gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
|
|
1028
|
-
input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
|
|
718
|
+
-- One row per record. Field cells live in the data JSON object, keyed by
|
|
719
|
+
-- property id (rename-safe). Each (record,property) is still an independent
|
|
720
|
+
-- CRDT register in the oplog; materialize() folds the winner into data via
|
|
721
|
+
-- json_set/json_remove. Hot fields get expression indexes on data->>'propid'
|
|
722
|
+
-- (see indexing.ts) so filter/sort/limit push down to SQL.
|
|
723
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
724
|
+
id TEXT PRIMARY KEY,
|
|
725
|
+
database_id TEXT,
|
|
726
|
+
created_hlc TEXT,
|
|
727
|
+
order_key TEXT,
|
|
728
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
729
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
730
|
+
);
|
|
731
|
+
CREATE INDEX IF NOT EXISTS idx_records_db_hlc ON records(database_id, created_hlc);
|
|
1029
732
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
733
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
734
|
+
id TEXT PRIMARY KEY,
|
|
735
|
+
title TEXT,
|
|
736
|
+
body TEXT, -- materialized cache: serialized from doc_blocks
|
|
737
|
+
database_id TEXT,
|
|
738
|
+
parent_id TEXT,
|
|
739
|
+
created_hlc TEXT,
|
|
740
|
+
order_key TEXT, -- fractional index among siblings (same parent_id); ties broken by created_hlc,id
|
|
741
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
742
|
+
);
|
|
1036
743
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
.proprow .v { flex:1; padding:7px 8px; border-radius:var(--radius-sm); cursor:text; min-height:33px; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
|
|
1050
|
-
.proprow .v:hover { background:var(--hover); }
|
|
1051
|
-
.peek-divider { height:1px; background:var(--line); margin:22px 0 18px; }
|
|
744
|
+
-- Document body as an ordered list of blocks; each (block,field) is an
|
|
745
|
+
-- independent CRDT register so concurrent edits to different blocks merge.
|
|
746
|
+
-- order_key is a fractional index (ties broken by id) giving display order.
|
|
747
|
+
CREATE TABLE IF NOT EXISTS doc_blocks (
|
|
748
|
+
id TEXT PRIMARY KEY,
|
|
749
|
+
doc_id TEXT,
|
|
750
|
+
text TEXT,
|
|
751
|
+
order_key TEXT,
|
|
752
|
+
blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
|
|
753
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
754
|
+
);
|
|
755
|
+
CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
|
|
1052
756
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
.pop .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
|
|
1062
|
-
.pop .item .lico.plain { border:0; width:22px; height:22px; }
|
|
1063
|
-
.pop .item .meta { flex:1; min-width:0; }
|
|
1064
|
-
.pop .item .t { font-size:13.5px; } .pop .item .d { font-size:11.5px; color:var(--muted); display:block; }
|
|
1065
|
-
.pop .item .chk { color:var(--accent); margin-left:auto; }
|
|
1066
|
-
.pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
|
|
1067
|
-
.pop .sep { height:1px; background:var(--line); margin:5px 4px; }
|
|
1068
|
-
.pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
|
|
1069
|
-
.pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
|
|
1070
|
-
.pop .typegrid .item .lico { margin:0 auto; }
|
|
1071
|
-
.pop input.field { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:7px 9px; outline:none; background:var(--surface); margin:2px; }
|
|
1072
|
-
.pop input.field:focus { border-color:var(--accent); }
|
|
1073
|
-
.optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
|
|
1074
|
-
.optrow .chip { flex:1; }
|
|
1075
|
-
.optrow .x { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
1076
|
-
.optrow .x:hover { background:var(--hover-2); color:var(--danger); }
|
|
757
|
+
-- A named static site (Supabase-Storage-style bucket). Served at /sites/<name>/.
|
|
758
|
+
CREATE TABLE IF NOT EXISTS sites (
|
|
759
|
+
id TEXT PRIMARY KEY,
|
|
760
|
+
name TEXT, -- URL slug; resolved by getSiteByName
|
|
761
|
+
title TEXT,
|
|
762
|
+
created_hlc TEXT,
|
|
763
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
764
|
+
);
|
|
1077
765
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
766
|
+
-- One row per file in a site. content holds inline utf8/base64 text, or a blob
|
|
767
|
+
-- hash (see cache.ts) when encoding = 'blob'. (site_id, path) maps to a stable
|
|
768
|
+
-- id so re-uploads merge as a CRDT register instead of duplicating.
|
|
769
|
+
CREATE TABLE IF NOT EXISTS site_files (
|
|
770
|
+
id TEXT PRIMARY KEY,
|
|
771
|
+
site_id TEXT,
|
|
772
|
+
path TEXT,
|
|
773
|
+
content_type TEXT,
|
|
774
|
+
encoding TEXT, -- 'utf8' | 'base64' | 'blob'
|
|
775
|
+
content TEXT,
|
|
776
|
+
created_hlc TEXT,
|
|
777
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
778
|
+
);
|
|
779
|
+
CREATE INDEX IF NOT EXISTS idx_site_files_site ON site_files(site_id);
|
|
780
|
+
`;
|
|
781
|
+
var FTS_SCHEMA = `
|
|
782
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
|
783
|
+
kind UNINDEXED,
|
|
784
|
+
id UNINDEXED,
|
|
785
|
+
database_id UNINDEXED,
|
|
786
|
+
title,
|
|
787
|
+
body
|
|
788
|
+
);
|
|
789
|
+
`;
|
|
1101
790
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
|
|
1106
|
-
@keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
|
|
791
|
+
// src/core/records.ts
|
|
792
|
+
init_ids();
|
|
793
|
+
init_crdt();
|
|
1107
794
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
795
|
+
// src/core/databases.ts
|
|
796
|
+
init_ids();
|
|
797
|
+
init_crdt();
|
|
798
|
+
init_errors();
|
|
799
|
+
function createDatabase(db, opts) {
|
|
800
|
+
const id = newId("db", opts.name);
|
|
801
|
+
const first = emit(db, "databases", id, "name", opts.name);
|
|
802
|
+
emit(db, "databases", id, "created_hlc", first.hlc);
|
|
803
|
+
if (opts.icon !== undefined)
|
|
804
|
+
emit(db, "databases", id, "icon", opts.icon);
|
|
805
|
+
return getDatabase(db, id);
|
|
806
|
+
}
|
|
807
|
+
function updateDatabase(db, id, fields) {
|
|
808
|
+
if (!getDatabase(db, id))
|
|
809
|
+
throw new MhError("not_found", `no such database: ${id}`);
|
|
810
|
+
if (fields.name !== undefined)
|
|
811
|
+
emit(db, "databases", id, "name", fields.name);
|
|
812
|
+
if (fields.icon !== undefined)
|
|
813
|
+
emit(db, "databases", id, "icon", fields.icon);
|
|
814
|
+
return getDatabase(db, id);
|
|
815
|
+
}
|
|
816
|
+
function getDatabase(db, id) {
|
|
817
|
+
return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE id = ? AND __deleted = 0").get(id);
|
|
818
|
+
}
|
|
819
|
+
function listDatabases(db) {
|
|
820
|
+
return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE __deleted = 0 ORDER BY created_hlc").all();
|
|
821
|
+
}
|
|
822
|
+
function deleteDatabase(db, id) {
|
|
823
|
+
if (!getDatabase(db, id))
|
|
824
|
+
return false;
|
|
825
|
+
emit(db, "databases", id, "__deleted", 1);
|
|
826
|
+
const tombstone = (table) => {
|
|
827
|
+
for (const r of liveChildren(db, table, "database_id", id))
|
|
828
|
+
emit(db, table, r, "__deleted", 1);
|
|
829
|
+
};
|
|
830
|
+
tombstone("properties");
|
|
831
|
+
tombstone("records");
|
|
832
|
+
for (const d of liveChildren(db, "documents", "database_id", id))
|
|
833
|
+
emit(db, "documents", d, "database_id", null);
|
|
834
|
+
db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
function liveChildren(db, table, col, parentId) {
|
|
838
|
+
return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
|
|
839
|
+
}
|
|
1111
840
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
.estate-art .stroke { fill:none; stroke:var(--line-strong); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
|
|
1116
|
-
.estate-art .fill-a { fill:var(--accent); }
|
|
1117
|
-
.estate-art .fill-soft { fill:var(--accent-soft); }
|
|
1118
|
-
.estate-art .fill-surface { fill:var(--bg); }
|
|
1119
|
-
.estate-art .ground { fill:var(--accent); }
|
|
1120
|
-
.estate-title { margin:28px 0 0; font-size:18px; font-weight:600; letter-spacing:.2px; color:var(--fg);
|
|
1121
|
-
opacity:0; animation:fadeUp .5s ease forwards 1.0s; }
|
|
1122
|
-
.estate-hint { margin:9px 0 0; font-size:13.5px; line-height:1.6; color:var(--muted); max-width:300px;
|
|
1123
|
-
opacity:0; animation:fadeUp .5s ease forwards 1.15s; }
|
|
1124
|
-
.estate-link { margin:18px 0 0; font-size:14px; font-weight:600; color:var(--accent);
|
|
1125
|
-
display:inline-flex; align-items:center; gap:6px; padding:9px 8px; border-radius:var(--radius-sm);
|
|
1126
|
-
opacity:0; animation:fadeUp .5s ease forwards 1.3s; transition:opacity .15s; }
|
|
1127
|
-
.estate-link:hover { opacity:.7; }
|
|
1128
|
-
/* intro */
|
|
1129
|
-
.draw { stroke-dasharray:100; stroke-dashoffset:100; }
|
|
1130
|
-
@keyframes draw { to { stroke-dashoffset:0; } }
|
|
1131
|
-
@keyframes leafGrow { from { opacity:0; transform:scale(.2) rotate(-10deg); } to { opacity:1; transform:scale(1) rotate(0); } }
|
|
1132
|
-
@keyframes cardUp { from { opacity:0; transform:translateY(16px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
|
|
1133
|
-
@keyframes groundIn { to { opacity:.1; } }
|
|
1134
|
-
@keyframes glowIn { to { opacity:.6; } }
|
|
1135
|
-
@keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
|
|
1136
|
-
/* idle loops \u2014 each 0% equals the settled rest state to avoid a jump */
|
|
1137
|
-
@keyframes idleFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
|
|
1138
|
-
@keyframes plantSway { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-1.3deg); } 75% { transform:rotate(1.3deg); } }
|
|
1139
|
-
@keyframes leafSwayA { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-2.6deg); } 75% { transform:rotate(2.6deg); } }
|
|
1140
|
-
@keyframes leafSwayB { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(2.6deg); } 75% { transform:rotate(-2.6deg); } }
|
|
1141
|
-
@keyframes groundBreathe { 0%,100% { transform:scale(1); opacity:.10; } 50% { transform:scale(1.07); opacity:.15; } }
|
|
1142
|
-
@keyframes glowPulse { 0%,100% { transform:scale(1); opacity:.6; } 50% { transform:scale(1.1); opacity:.85; } }
|
|
1143
|
-
@keyframes particle { 0% { opacity:0; transform:translate(0,0) scale(.4); } 35% { opacity:.85; transform:translate(2px,-4px) scale(1); } 70% { opacity:0; transform:translate(4px,-9px) scale(.5); } 100% { opacity:0; transform:translate(0,0) scale(.4); } }
|
|
1144
|
-
/* wiring */
|
|
1145
|
-
.kg-ground { transform-box:fill-box; transform-origin:center; opacity:0;
|
|
1146
|
-
animation:groundIn .5s ease forwards .1s, groundBreathe 6s ease-in-out 1.6s infinite; }
|
|
1147
|
-
.kg-glow { filter:blur(9px); transform-box:fill-box; transform-origin:center; opacity:0;
|
|
1148
|
-
animation:glowIn .6s ease forwards .1s, glowPulse 4.6s ease-in-out 1.6s infinite; }
|
|
1149
|
-
.kg-cardB { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .2s; }
|
|
1150
|
-
.kg-cardA { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .32s; }
|
|
1151
|
-
.kg-plant { transform-box:fill-box; transform-origin:50% 100%; animation:plantSway 6s ease-in-out 1.7s infinite; }
|
|
1152
|
-
.kg-stem { animation:draw .9s ease-out forwards .4s; }
|
|
1153
|
-
.kg-leaf { transform-box:fill-box; }
|
|
1154
|
-
.kg-grow { transform-box:fill-box; opacity:0; }
|
|
1155
|
-
.kg-leaf.l1 { transform-origin:90% 100%; animation:leafSwayA 4.6s ease-in-out 1.7s infinite; }
|
|
1156
|
-
.kg-leaf.l2 { transform-origin:0% 80%; animation:leafSwayB 5.2s ease-in-out 1.8s infinite; }
|
|
1157
|
-
.kg-leaf.l3 { transform-origin:0% 100%; animation:leafSwayA 5.0s ease-in-out 1.9s infinite; }
|
|
1158
|
-
.kg-leaf.l1 .kg-grow { transform-origin:90% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards .85s; }
|
|
1159
|
-
.kg-leaf.l2 .kg-grow { transform-origin:0% 80%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.0s; }
|
|
1160
|
-
.kg-leaf.l3 .kg-grow { transform-origin:0% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.15s; }
|
|
1161
|
-
.kg-particle { transform-box:fill-box; transform-origin:center; opacity:0; }
|
|
1162
|
-
.kg-particle.p1 { animation:particle 4.5s ease-in-out 1.6s infinite; }
|
|
1163
|
-
.kg-particle.p2 { animation:particle 5.2s ease-in-out 2.0s infinite; }
|
|
1164
|
-
.kg-particle.p3 { animation:particle 4.8s ease-in-out 2.6s infinite; }
|
|
1165
|
-
.kg-particle.p4 { animation:particle 5.6s ease-in-out 3.1s infinite; }
|
|
1166
|
-
.kg-particle.p5 { animation:particle 4.2s ease-in-out 2.3s infinite; }
|
|
1167
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1168
|
-
.estate-art, .estate-art *, .estate-title, .estate-hint, .estate-link { animation:none !important; opacity:1 !important; transform:none !important; }
|
|
1169
|
-
.draw { stroke-dashoffset:0 !important; }
|
|
1170
|
-
.kg-ground { opacity:.1 !important; }
|
|
1171
|
-
.kg-glow { opacity:.6 !important; }
|
|
1172
|
-
.kg-particle { opacity:0 !important; }
|
|
1173
|
-
}
|
|
1174
|
-
.error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
|
|
1175
|
-
.view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
|
|
1176
|
-
.ico.flip { transform:scaleX(-1); }
|
|
1177
|
-
|
|
1178
|
-
/* ---- board (kanban) view ---- */
|
|
1179
|
-
.board-wrap { margin-top:4px; }
|
|
1180
|
-
.board-bar { display:flex; align-items:center; gap:10px; padding:2px 0 12px; }
|
|
1181
|
-
.board { display:flex; align-items:flex-start; gap:12px; overflow-x:auto; padding-bottom:14px; min-height:240px; }
|
|
1182
|
-
.board-col { flex:0 0 272px; width:272px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); display:flex; flex-direction:column; max-height:calc(100vh - 240px); transition:box-shadow .12s,border-color .12s; }
|
|
1183
|
-
.board-col.drop-into { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); }
|
|
1184
|
-
.board-col-head { display:flex; align-items:center; gap:8px; padding:10px 10px 8px; }
|
|
1185
|
-
.board-col-head .spacer { flex:1; }
|
|
1186
|
-
.board-count { color:var(--muted); font-size:12px; font-weight:600; }
|
|
1187
|
-
.board-col-head .iconbtn.sm { width:24px; height:24px; }
|
|
1188
|
-
.board-col-body { display:flex; flex-direction:column; gap:7px; padding:0 8px 8px; overflow-y:auto; }
|
|
1189
|
-
.kcard { background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:9px 10px; box-shadow:var(--shadow-sm);
|
|
1190
|
-
cursor:grab; display:flex; flex-direction:column; gap:5px; touch-action:none; transition:border-color .1s,box-shadow .1s; }
|
|
1191
|
-
.kcard:hover { border-color:var(--line-strong); }
|
|
1192
|
-
.kcard.drag-source { opacity:.4; }
|
|
1193
|
-
.kcard.dragging-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; cursor:grabbing;
|
|
1194
|
-
box-shadow:var(--shadow-lg); opacity:.95; transform:translate3d(-9999px,0,0); }
|
|
1195
|
-
.kcard-title { font-size:13.5px; font-weight:600; line-height:1.35; word-break:break-word; }
|
|
1196
|
-
.kcard-field { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--fg-soft); min-width:0; }
|
|
1197
|
-
.kcard-field svg { width:13px; height:13px; color:var(--muted); flex:none; }
|
|
1198
|
-
.board-add { display:flex; align-items:center; gap:6px; padding:7px 8px; color:var(--muted); font-size:12.5px; border-radius:var(--radius-sm); }
|
|
1199
|
-
.board-add:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
1200
|
-
|
|
1201
|
-
/* ---- calendar (month) view ---- */
|
|
1202
|
-
.cal { margin-top:4px; display:flex; flex-direction:column; }
|
|
1203
|
-
.cal-nav { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
|
|
1204
|
-
.cal-nav .spacer { flex:1; }
|
|
1205
|
-
.cal-title { font-size:15px; font-weight:650; min-width:120px; }
|
|
1206
|
-
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
|
1207
|
-
.cal-head { border:1px solid var(--line); border-bottom:0; border-radius:var(--radius) var(--radius) 0 0; background:var(--surface); }
|
|
1208
|
-
.cal-wd { padding:8px 10px; font-size:12px; font-weight:600; color:var(--muted); text-align:left; border-right:1px solid var(--line); }
|
|
1209
|
-
.cal-wd:last-child { border-right:0; }
|
|
1210
|
-
.cal-body { border:1px solid var(--line); border-radius:0 0 var(--radius) var(--radius); overflow:hidden; }
|
|
1211
|
-
.cal-week { border-bottom:1px solid var(--line); }
|
|
1212
|
-
.cal-week:last-child { border-bottom:0; }
|
|
1213
|
-
.cal-day { min-height:108px; border-right:1px solid var(--line); padding:5px 6px; display:flex; flex-direction:column; gap:4px; transition:background .1s; cursor:pointer; }
|
|
1214
|
-
.cal-day:last-child { border-right:0; }
|
|
1215
|
-
.cal-day:hover { background:var(--hover); }
|
|
1216
|
-
.cal-day.dim { background:var(--surface); color:var(--muted); }
|
|
1217
|
-
.cal-day.dim:hover { background:var(--hover); }
|
|
1218
|
-
.cal-day.drop-into { background:var(--accent-soft); }
|
|
1219
|
-
.cal-daynum { display:flex; align-items:center; justify-content:space-between; font-size:12.5px; font-weight:600; color:var(--fg-soft); }
|
|
1220
|
-
.cal-day.dim .cal-daynum { color:var(--muted); }
|
|
1221
|
-
.cal-day.today .cal-daynum span:first-child { background:var(--accent); color:var(--accent-fg); width:21px; height:21px; border-radius:50%; display:grid; place-items:center; }
|
|
1222
|
-
.cal-add { opacity:0; width:20px; height:20px; display:grid; place-items:center; color:var(--muted); border-radius:5px; transition:opacity .12s; }
|
|
1223
|
-
.cal-day:hover .cal-add { opacity:1; }
|
|
1224
|
-
.cal-add:hover { background:var(--hover-2); color:var(--fg); }
|
|
1225
|
-
.cal-events { display:flex; flex-direction:column; gap:3px; }
|
|
1226
|
-
.cal-ev { font-size:12px; padding:2px 7px; border-radius:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:grab; touch-action:none;
|
|
1227
|
-
background:color-mix(in srgb,var(--c) 16%,transparent); color:var(--c); border-left:2px solid var(--c); }
|
|
1228
|
-
.cal-ev:hover { filter:brightness(1.04); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c) 40%,transparent); }
|
|
1229
|
-
@media (prefers-color-scheme: dark) { .cal-ev { background:color-mix(in srgb,var(--c) 24%,transparent); color:color-mix(in srgb,var(--c) 78%,#fff); } }
|
|
1230
|
-
.cal-more { display:block; width:100%; text-align:left; font-size:11.5px; color:var(--muted); padding:2px 7px; border-radius:5px; }
|
|
1231
|
-
.cal-more:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
1232
|
-
|
|
1233
|
-
/* ---- timeline (gantt) view ---- */
|
|
1234
|
-
.tl { margin-top:4px; }
|
|
1235
|
-
.tl-toolbar { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
|
|
1236
|
-
.tl-scroll { overflow:auto; border:1px solid var(--line); border-radius:var(--radius); max-height:calc(100vh - 230px); }
|
|
1237
|
-
.tl-canvas { position:relative; }
|
|
1238
|
-
.tl-axis { position:sticky; top:0; z-index:4; background:var(--surface); border-bottom:1px solid var(--line); }
|
|
1239
|
-
.tl-corner { position:sticky; left:0; z-index:5; height:100%; display:flex; align-items:center; padding:0 12px; font-size:12px; font-weight:600; color:var(--muted);
|
|
1240
|
-
background:var(--surface); border-right:1px solid var(--line); }
|
|
1241
|
-
.tl-tick { position:absolute; top:0; height:100%; box-sizing:border-box; border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:flex-end; padding:0 0 4px 4px; }
|
|
1242
|
-
.tl-tick.mstart { border-right-color:var(--line-strong); }
|
|
1243
|
-
.tl-tick .tl-month { position:absolute; top:5px; left:4px; font-size:10.5px; font-weight:700; color:var(--fg-soft); white-space:nowrap; }
|
|
1244
|
-
.tl-tick .tl-dnum { font-size:11px; color:var(--muted); }
|
|
1245
|
-
.tl-tick.today .tl-dnum { color:var(--accent); font-weight:700; }
|
|
1246
|
-
.tl-row { position:absolute; left:0; right:0; border-bottom:1px solid var(--line); }
|
|
1247
|
-
.tl-rowlabel { position:sticky; left:0; z-index:3; height:100%; display:flex; align-items:center; padding:0 12px; font-size:13px; font-weight:500;
|
|
1248
|
-
background:var(--bg); border-right:1px solid var(--line); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
1249
|
-
.tl-item { position:absolute; top:7px; height:22px; box-sizing:border-box; background:var(--accent); color:var(--accent-fg); border-radius:6px;
|
|
1250
|
-
display:flex; align-items:center; gap:2px; padding:0 4px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); overflow:hidden; }
|
|
1251
|
-
.tl-item .tl-label { flex:1; font-size:12px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
|
|
1252
|
-
.tl-row:hover .tl-item { box-shadow:0 0 0 1px var(--accent), var(--shadow-sm); }
|
|
1253
|
-
.tl-item .tl-h { width:8px; align-self:stretch; flex:none; cursor:col-resize; border-radius:6px; opacity:0; transition:opacity .1s; }
|
|
1254
|
-
.tl-item:hover .tl-h { opacity:1; background:rgba(255,255,255,.35); }
|
|
1255
|
-
.tl-item .tl-h:hover { background:rgba(255,255,255,.6); }
|
|
1256
|
-
.tl-milestone { position:absolute; top:9px; width:18px; height:18px; transform:rotate(45deg); background:var(--accent); border-radius:3px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); margin-left:-9px; }
|
|
1257
|
-
.tl-milestone:hover { box-shadow:0 0 0 2px var(--accent-soft), var(--shadow-sm); }
|
|
1258
|
-
.tl-track-empty { position:absolute; top:0; right:0; height:100%; cursor:copy; }
|
|
1259
|
-
.tl-track-empty:hover { background:var(--hover); }
|
|
1260
|
-
.tl-schedule { position:sticky; top:7px; height:22px; display:inline-flex; align-items:center; padding:0 10px; border-radius:6px; font-size:12px;
|
|
1261
|
-
color:var(--muted); border:1px dashed var(--line-strong); background:var(--bg); white-space:nowrap; }
|
|
1262
|
-
.tl-schedule:hover { color:var(--accent); border-color:var(--accent); }
|
|
1263
|
-
.tl-today { position:absolute; top:0; width:2px; background:var(--accent); opacity:.55; z-index:2; pointer-events:none; }
|
|
1264
|
-
.tl-guide { position:absolute; top:0; width:0; border-left:1px dashed var(--accent); z-index:6; pointer-events:none; }
|
|
1265
|
-
.tl-guide-label { position:absolute; top:2px; left:4px; font-size:11px; font-weight:600; color:var(--accent-fg); background:var(--accent);
|
|
1266
|
-
padding:1px 6px; border-radius:5px; white-space:nowrap; box-shadow:var(--shadow-sm); }
|
|
841
|
+
// src/core/properties.ts
|
|
842
|
+
init_ids();
|
|
843
|
+
init_crdt();
|
|
1267
844
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
845
|
+
// src/core/indexing.ts
|
|
846
|
+
var AUTO_INDEX_ROW_THRESHOLD = 2000;
|
|
847
|
+
function sanitize(s) {
|
|
848
|
+
return s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
849
|
+
}
|
|
850
|
+
function lit(s) {
|
|
851
|
+
return s.replace(/'/g, "''");
|
|
852
|
+
}
|
|
853
|
+
function indexName(databaseId, propId) {
|
|
854
|
+
return `idx_rec_${sanitize(databaseId)}_${sanitize(propId)}`;
|
|
855
|
+
}
|
|
856
|
+
function hasIndex(db, databaseId, propId) {
|
|
857
|
+
const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName(databaseId, propId));
|
|
858
|
+
return row != null;
|
|
859
|
+
}
|
|
860
|
+
function ensurePropIndex(db, databaseId, propId) {
|
|
861
|
+
const name = indexName(databaseId, propId);
|
|
862
|
+
db.exec(`CREATE INDEX IF NOT EXISTS "${name}" ON records (data ->> '${lit(propId)}', created_hlc) ` + `WHERE database_id = '${lit(databaseId)}' AND __deleted = 0`);
|
|
863
|
+
}
|
|
864
|
+
function maybeAutoIndex(db, databaseId, prop) {
|
|
865
|
+
if (hasIndex(db, databaseId, prop.id))
|
|
866
|
+
return;
|
|
867
|
+
if (prop.type !== "relation") {
|
|
868
|
+
const row = db.query("SELECT COUNT(*) AS c FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId);
|
|
869
|
+
if (row.c < AUTO_INDEX_ROW_THRESHOLD)
|
|
870
|
+
return;
|
|
1281
871
|
}
|
|
872
|
+
ensurePropIndex(db, databaseId, prop.id);
|
|
873
|
+
}
|
|
1282
874
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
.qn-body .doc-meta { display:none; }
|
|
1305
|
-
/* A native checkbox paints an opaque white box when unchecked, which breaks
|
|
1306
|
-
the window's vibrancy blur. Draw a custom box so the unchecked background
|
|
1307
|
-
stays transparent; checked falls back to the accent fill + checkmark. */
|
|
1308
|
-
body.quicknote .b-todo .marker input {
|
|
1309
|
-
appearance:none; -webkit-appearance:none; box-sizing:border-box;
|
|
1310
|
-
border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
|
|
1311
|
-
border-radius:4px; background:transparent; }
|
|
1312
|
-
body.quicknote .b-todo .marker input:checked {
|
|
1313
|
-
border-color:var(--accent);
|
|
1314
|
-
background:var(--accent) center/10px no-repeat
|
|
1315
|
-
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
|
|
1316
|
-
/* Compact menus inside the quick-note window only (dropdown + slash command
|
|
1317
|
-
share .pop/.item/.lico with the main app; scope under body.quicknote so the
|
|
1318
|
-
main app keeps its roomier density). */
|
|
1319
|
-
body.quicknote .pop { padding:5px; border-radius:9px; }
|
|
1320
|
-
body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
|
|
1321
|
-
body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:12.5px; }
|
|
1322
|
-
body.quicknote .pop .item .t { font-size:12.5px; }
|
|
1323
|
-
body.quicknote .pop .item .d { font-size:10.5px; }
|
|
1324
|
-
body.quicknote .pop .item .lico { width:22px; height:22px; }
|
|
1325
|
-
body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
|
|
1326
|
-
body.quicknote .pop .sep { margin:4px 4px; }
|
|
1327
|
-
|
|
1328
|
-
/* Quick Notes settings rows (desktop only). */
|
|
1329
|
-
.qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
|
|
1330
|
-
.qn-set-row:first-of-type { border-top:0; }
|
|
1331
|
-
.qn-set-main { flex:1; min-width:0; }
|
|
1332
|
-
.qn-set-name { font-weight:600; }
|
|
1333
|
-
.qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
|
|
1334
|
-
.qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
|
|
1335
|
-
.qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
|
|
1336
|
-
</style>
|
|
1337
|
-
</head>
|
|
1338
|
-
<body>
|
|
1339
|
-
<div id="app"></div>
|
|
1340
|
-
<script type="module" src="/webui.js"></script>
|
|
1341
|
-
</body>
|
|
1342
|
-
</html>`, cachedJs = null;
|
|
1343
|
-
var init_webui = () => {};
|
|
1344
|
-
|
|
1345
|
-
// src/core/sync/sites-serve.ts
|
|
1346
|
-
var exports_sites_serve = {};
|
|
1347
|
-
__export(exports_sites_serve, {
|
|
1348
|
-
serveSite: () => serveSite
|
|
1349
|
-
});
|
|
1350
|
-
async function serveSite(req, ctx) {
|
|
1351
|
-
const url2 = new URL(req.url);
|
|
1352
|
-
const rest = url2.pathname.slice("/sites/".length);
|
|
1353
|
-
if (rest === "")
|
|
1354
|
-
return null;
|
|
1355
|
-
const slash = rest.indexOf("/");
|
|
1356
|
-
const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
|
|
1357
|
-
if (slash === -1)
|
|
1358
|
-
return Response.redirect(`${url2.origin}/sites/${name}/`, 301);
|
|
1359
|
-
const filePath = decodeURIComponent(rest.slice(slash + 1));
|
|
1360
|
-
let siteId;
|
|
1361
|
-
try {
|
|
1362
|
-
siteId = resolveSite(ctx.db, name).id;
|
|
1363
|
-
} catch {
|
|
1364
|
-
return new Response("site not found", { status: 404 });
|
|
1365
|
-
}
|
|
1366
|
-
const file2 = await getFileForServe(ctx.db, siteId, filePath);
|
|
1367
|
-
if (!file2)
|
|
1368
|
-
return new Response("not found", { status: 404 });
|
|
1369
|
-
return new Response(file2.bytes, { headers: { "content-type": file2.contentType } });
|
|
1370
|
-
}
|
|
1371
|
-
var init_sites_serve = __esm(() => {
|
|
1372
|
-
init_sites();
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
// src/core/index.ts
|
|
1376
|
-
init_paths();
|
|
1377
|
-
|
|
1378
|
-
// src/core/db.ts
|
|
1379
|
-
init_paths();
|
|
1380
|
-
import { Database } from "bun:sqlite";
|
|
1381
|
-
import { mkdirSync } from "fs";
|
|
1382
|
-
|
|
1383
|
-
// src/core/schema.ts
|
|
1384
|
-
var CORE_SCHEMA = `
|
|
1385
|
-
CREATE TABLE IF NOT EXISTS meta (
|
|
1386
|
-
key TEXT PRIMARY KEY,
|
|
1387
|
-
value TEXT
|
|
1388
|
-
);
|
|
1389
|
-
|
|
1390
|
-
-- CRDT oplog: one row per field assignment. Source of truth for sync.
|
|
1391
|
-
CREATE TABLE IF NOT EXISTS crdt_changes (
|
|
1392
|
-
hlc TEXT NOT NULL,
|
|
1393
|
-
node_id TEXT NOT NULL,
|
|
1394
|
-
dataset TEXT NOT NULL,
|
|
1395
|
-
row_id TEXT NOT NULL,
|
|
1396
|
-
col TEXT NOT NULL,
|
|
1397
|
-
value TEXT,
|
|
1398
|
-
PRIMARY KEY (dataset, row_id, col, hlc)
|
|
1399
|
-
);
|
|
1400
|
-
CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
|
|
1401
|
-
|
|
1402
|
-
-- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
|
|
1403
|
-
-- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
|
|
1404
|
-
-- a pairing: "token" is the credential the *remote* issued to us (sent as a
|
|
1405
|
-
-- Bearer header when we sync to them). See migratePeers in db.ts for the columns
|
|
1406
|
-
-- added to legacy databases.
|
|
1407
|
-
CREATE TABLE IF NOT EXISTS peers (
|
|
1408
|
-
url TEXT PRIMARY KEY,
|
|
1409
|
-
pull_cursor INTEGER NOT NULL DEFAULT 0,
|
|
1410
|
-
push_cursor INTEGER NOT NULL DEFAULT 0,
|
|
1411
|
-
token TEXT,
|
|
1412
|
-
label TEXT,
|
|
1413
|
-
node_id TEXT,
|
|
1414
|
-
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1415
|
-
last_sync_at INTEGER,
|
|
1416
|
-
last_status TEXT,
|
|
1417
|
-
last_error TEXT
|
|
1418
|
-
);
|
|
1419
|
-
|
|
1420
|
-
-- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
|
|
1421
|
-
-- presents one of these on /sync; acceptsSyncToken() checks it alongside the
|
|
1422
|
-
-- managed master token. Revoke by deleting the row.
|
|
1423
|
-
CREATE TABLE IF NOT EXISTS peer_grants (
|
|
1424
|
-
token TEXT PRIMARY KEY,
|
|
1425
|
-
peer_url TEXT,
|
|
1426
|
-
node_id TEXT,
|
|
1427
|
-
created_at INTEGER
|
|
1428
|
-
);
|
|
1429
|
-
|
|
1430
|
-
-- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
|
|
1431
|
-
-- Short-lived and single-use: redeemed during the pairing handshake, then the
|
|
1432
|
-
-- two devices exchange durable peer_grants. used/exp gate redemption.
|
|
1433
|
-
CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
1434
|
-
code TEXT PRIMARY KEY,
|
|
1435
|
-
exp INTEGER NOT NULL,
|
|
1436
|
-
used INTEGER NOT NULL DEFAULT 0,
|
|
1437
|
-
created_at INTEGER
|
|
1438
|
-
);
|
|
1439
|
-
|
|
1440
|
-
CREATE TABLE IF NOT EXISTS databases (
|
|
1441
|
-
id TEXT PRIMARY KEY,
|
|
1442
|
-
name TEXT,
|
|
1443
|
-
icon TEXT,
|
|
1444
|
-
created_hlc TEXT,
|
|
1445
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1446
|
-
);
|
|
1447
|
-
|
|
1448
|
-
CREATE TABLE IF NOT EXISTS properties (
|
|
1449
|
-
id TEXT PRIMARY KEY,
|
|
1450
|
-
database_id TEXT,
|
|
1451
|
-
name TEXT,
|
|
1452
|
-
type TEXT,
|
|
1453
|
-
config TEXT,
|
|
1454
|
-
position REAL,
|
|
1455
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1456
|
-
);
|
|
1457
|
-
CREATE INDEX IF NOT EXISTS idx_properties_db ON properties(database_id);
|
|
1458
|
-
|
|
1459
|
-
-- One row per record. Field cells live in the data JSON object, keyed by
|
|
1460
|
-
-- property id (rename-safe). Each (record,property) is still an independent
|
|
1461
|
-
-- CRDT register in the oplog; materialize() folds the winner into data via
|
|
1462
|
-
-- json_set/json_remove. Hot fields get expression indexes on data->>'propid'
|
|
1463
|
-
-- (see indexing.ts) so filter/sort/limit push down to SQL.
|
|
1464
|
-
CREATE TABLE IF NOT EXISTS records (
|
|
1465
|
-
id TEXT PRIMARY KEY,
|
|
1466
|
-
database_id TEXT,
|
|
1467
|
-
created_hlc TEXT,
|
|
1468
|
-
order_key TEXT,
|
|
1469
|
-
data TEXT NOT NULL DEFAULT '{}',
|
|
1470
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1471
|
-
);
|
|
1472
|
-
CREATE INDEX IF NOT EXISTS idx_records_db_hlc ON records(database_id, created_hlc);
|
|
1473
|
-
|
|
1474
|
-
CREATE TABLE IF NOT EXISTS documents (
|
|
1475
|
-
id TEXT PRIMARY KEY,
|
|
1476
|
-
title TEXT,
|
|
1477
|
-
body TEXT, -- materialized cache: serialized from doc_blocks
|
|
1478
|
-
database_id TEXT,
|
|
1479
|
-
parent_id TEXT,
|
|
1480
|
-
created_hlc TEXT,
|
|
1481
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1482
|
-
);
|
|
1483
|
-
|
|
1484
|
-
-- Document body as an ordered list of blocks; each (block,field) is an
|
|
1485
|
-
-- independent CRDT register so concurrent edits to different blocks merge.
|
|
1486
|
-
-- order_key is a fractional index (ties broken by id) giving display order.
|
|
1487
|
-
CREATE TABLE IF NOT EXISTS doc_blocks (
|
|
1488
|
-
id TEXT PRIMARY KEY,
|
|
1489
|
-
doc_id TEXT,
|
|
1490
|
-
text TEXT,
|
|
1491
|
-
order_key TEXT,
|
|
1492
|
-
blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
|
|
1493
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1494
|
-
);
|
|
1495
|
-
CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
|
|
1496
|
-
|
|
1497
|
-
-- A named static site (Supabase-Storage-style bucket). Served at /sites/<name>/.
|
|
1498
|
-
CREATE TABLE IF NOT EXISTS sites (
|
|
1499
|
-
id TEXT PRIMARY KEY,
|
|
1500
|
-
name TEXT, -- URL slug; resolved by getSiteByName
|
|
1501
|
-
title TEXT,
|
|
1502
|
-
created_hlc TEXT,
|
|
1503
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1504
|
-
);
|
|
1505
|
-
|
|
1506
|
-
-- One row per file in a site. content holds inline utf8/base64 text, or a blob
|
|
1507
|
-
-- hash (see cache.ts) when encoding = 'blob'. (site_id, path) maps to a stable
|
|
1508
|
-
-- id so re-uploads merge as a CRDT register instead of duplicating.
|
|
1509
|
-
CREATE TABLE IF NOT EXISTS site_files (
|
|
1510
|
-
id TEXT PRIMARY KEY,
|
|
1511
|
-
site_id TEXT,
|
|
1512
|
-
path TEXT,
|
|
1513
|
-
content_type TEXT,
|
|
1514
|
-
encoding TEXT, -- 'utf8' | 'base64' | 'blob'
|
|
1515
|
-
content TEXT,
|
|
1516
|
-
created_hlc TEXT,
|
|
1517
|
-
__deleted INTEGER NOT NULL DEFAULT 0
|
|
1518
|
-
);
|
|
1519
|
-
CREATE INDEX IF NOT EXISTS idx_site_files_site ON site_files(site_id);
|
|
1520
|
-
`;
|
|
1521
|
-
var FTS_SCHEMA = `
|
|
1522
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
|
1523
|
-
kind UNINDEXED,
|
|
1524
|
-
id UNINDEXED,
|
|
1525
|
-
database_id UNINDEXED,
|
|
1526
|
-
title,
|
|
1527
|
-
body
|
|
1528
|
-
);
|
|
1529
|
-
`;
|
|
1530
|
-
|
|
1531
|
-
// src/core/records.ts
|
|
1532
|
-
init_ids();
|
|
1533
|
-
init_crdt();
|
|
1534
|
-
|
|
1535
|
-
// src/core/databases.ts
|
|
1536
|
-
init_ids();
|
|
1537
|
-
init_crdt();
|
|
1538
|
-
function createDatabase(db, opts) {
|
|
1539
|
-
const id = newId("db", opts.name);
|
|
1540
|
-
const first = emit(db, "databases", id, "name", opts.name);
|
|
1541
|
-
emit(db, "databases", id, "created_hlc", first.hlc);
|
|
1542
|
-
if (opts.icon !== undefined)
|
|
1543
|
-
emit(db, "databases", id, "icon", opts.icon);
|
|
1544
|
-
return getDatabase(db, id);
|
|
1545
|
-
}
|
|
1546
|
-
function updateDatabase(db, id, fields) {
|
|
1547
|
-
if (!getDatabase(db, id))
|
|
1548
|
-
throw new Error(`no such database: ${id}`);
|
|
1549
|
-
if (fields.name !== undefined)
|
|
1550
|
-
emit(db, "databases", id, "name", fields.name);
|
|
1551
|
-
if (fields.icon !== undefined)
|
|
1552
|
-
emit(db, "databases", id, "icon", fields.icon);
|
|
1553
|
-
return getDatabase(db, id);
|
|
1554
|
-
}
|
|
1555
|
-
function getDatabase(db, id) {
|
|
1556
|
-
return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE id = ? AND __deleted = 0").get(id);
|
|
1557
|
-
}
|
|
1558
|
-
function listDatabases(db) {
|
|
1559
|
-
return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE __deleted = 0 ORDER BY created_hlc").all();
|
|
1560
|
-
}
|
|
1561
|
-
function deleteDatabase(db, id) {
|
|
1562
|
-
if (!getDatabase(db, id))
|
|
1563
|
-
return false;
|
|
1564
|
-
emit(db, "databases", id, "__deleted", 1);
|
|
1565
|
-
const tombstone = (table) => {
|
|
1566
|
-
for (const r of liveChildren(db, table, "database_id", id))
|
|
1567
|
-
emit(db, table, r, "__deleted", 1);
|
|
1568
|
-
};
|
|
1569
|
-
tombstone("properties");
|
|
1570
|
-
tombstone("records");
|
|
1571
|
-
for (const d of liveChildren(db, "documents", "database_id", id))
|
|
1572
|
-
emit(db, "documents", d, "database_id", null);
|
|
1573
|
-
db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
|
|
1574
|
-
return true;
|
|
1575
|
-
}
|
|
1576
|
-
function liveChildren(db, table, col, parentId) {
|
|
1577
|
-
return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// src/core/properties.ts
|
|
1581
|
-
init_ids();
|
|
1582
|
-
init_crdt();
|
|
1583
|
-
|
|
1584
|
-
// src/core/indexing.ts
|
|
1585
|
-
var AUTO_INDEX_ROW_THRESHOLD = 2000;
|
|
1586
|
-
function sanitize(s) {
|
|
1587
|
-
return s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1588
|
-
}
|
|
1589
|
-
function lit(s) {
|
|
1590
|
-
return s.replace(/'/g, "''");
|
|
1591
|
-
}
|
|
1592
|
-
function indexName(databaseId, propId) {
|
|
1593
|
-
return `idx_rec_${sanitize(databaseId)}_${sanitize(propId)}`;
|
|
1594
|
-
}
|
|
1595
|
-
function hasIndex(db, databaseId, propId) {
|
|
1596
|
-
const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName(databaseId, propId));
|
|
1597
|
-
return row != null;
|
|
1598
|
-
}
|
|
1599
|
-
function ensurePropIndex(db, databaseId, propId) {
|
|
1600
|
-
const name = indexName(databaseId, propId);
|
|
1601
|
-
db.exec(`CREATE INDEX IF NOT EXISTS "${name}" ON records (data ->> '${lit(propId)}', created_hlc) ` + `WHERE database_id = '${lit(databaseId)}' AND __deleted = 0`);
|
|
1602
|
-
}
|
|
1603
|
-
function maybeAutoIndex(db, databaseId, prop) {
|
|
1604
|
-
if (hasIndex(db, databaseId, prop.id))
|
|
1605
|
-
return;
|
|
1606
|
-
if (prop.type !== "relation") {
|
|
1607
|
-
const row = db.query("SELECT COUNT(*) AS c FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId);
|
|
1608
|
-
if (row.c < AUTO_INDEX_ROW_THRESHOLD)
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
ensurePropIndex(db, databaseId, prop.id);
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
// src/core/properties.ts
|
|
1615
|
-
var PROP_TYPES = new Set([
|
|
1616
|
-
"text",
|
|
1617
|
-
"number",
|
|
1618
|
-
"checkbox",
|
|
1619
|
-
"select",
|
|
1620
|
-
"multi_select",
|
|
1621
|
-
"date",
|
|
1622
|
-
"relation",
|
|
1623
|
-
"url"
|
|
1624
|
-
]);
|
|
1625
|
-
function validateConfig(type, config) {
|
|
1626
|
-
if (type === "select" || type === "multi_select") {
|
|
1627
|
-
const opts = config?.options;
|
|
1628
|
-
if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
|
|
1629
|
-
throw new Error(`${type} requires config.options: string[]`);
|
|
1630
|
-
}
|
|
1631
|
-
if (type === "relation") {
|
|
1632
|
-
if (typeof config?.database !== "string")
|
|
1633
|
-
throw new Error("relation requires config.database (target database id)");
|
|
875
|
+
// src/core/properties.ts
|
|
876
|
+
init_errors();
|
|
877
|
+
var PROP_TYPES = new Set([
|
|
878
|
+
"text",
|
|
879
|
+
"number",
|
|
880
|
+
"checkbox",
|
|
881
|
+
"select",
|
|
882
|
+
"multi_select",
|
|
883
|
+
"date",
|
|
884
|
+
"relation",
|
|
885
|
+
"url"
|
|
886
|
+
]);
|
|
887
|
+
function validateConfig(type, config) {
|
|
888
|
+
if (type === "select" || type === "multi_select") {
|
|
889
|
+
const opts = config?.options;
|
|
890
|
+
if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
|
|
891
|
+
throw new MhError("invalid_input", `${type} requires config.options: string[]`);
|
|
892
|
+
}
|
|
893
|
+
if (type === "relation") {
|
|
894
|
+
if (typeof config?.database !== "string")
|
|
895
|
+
throw new MhError("invalid_input", "relation requires config.database (target database id)");
|
|
1634
896
|
}
|
|
1635
897
|
}
|
|
1636
898
|
function nextPosition(db, databaseId) {
|
|
@@ -1639,9 +901,9 @@ function nextPosition(db, databaseId) {
|
|
|
1639
901
|
}
|
|
1640
902
|
function addProperty(db, databaseId, opts) {
|
|
1641
903
|
if (!getDatabase(db, databaseId))
|
|
1642
|
-
throw new
|
|
904
|
+
throw new MhError("not_found", `no such database: ${databaseId}`);
|
|
1643
905
|
if (!PROP_TYPES.has(opts.type))
|
|
1644
|
-
throw new
|
|
906
|
+
throw new MhError("invalid_input", `unknown property type: ${opts.type}`);
|
|
1645
907
|
validateConfig(opts.type, opts.config);
|
|
1646
908
|
const config = opts.indexed ? { ...opts.config ?? {}, indexed: true } : opts.config ?? null;
|
|
1647
909
|
const id = newId("prop", opts.name);
|
|
@@ -1670,10 +932,10 @@ function listProperties(db, databaseId) {
|
|
|
1670
932
|
function updateProperty(db, id, fields) {
|
|
1671
933
|
const cur = getProperty(db, id);
|
|
1672
934
|
if (!cur)
|
|
1673
|
-
throw new
|
|
935
|
+
throw new MhError("not_found", `no such property: ${id}`);
|
|
1674
936
|
const typeChanged = fields.type !== undefined && fields.type !== cur.type;
|
|
1675
937
|
if (fields.type !== undefined && !PROP_TYPES.has(fields.type))
|
|
1676
|
-
throw new
|
|
938
|
+
throw new MhError("invalid_input", `unknown property type: ${fields.type}`);
|
|
1677
939
|
const effectiveType = fields.type ?? cur.type;
|
|
1678
940
|
if (fields.config !== undefined)
|
|
1679
941
|
validateConfig(effectiveType, fields.config);
|
|
@@ -1697,9 +959,9 @@ function updateProperty(db, id, fields) {
|
|
|
1697
959
|
function setPropertyWidth(db, id, width) {
|
|
1698
960
|
const cur = getProperty(db, id);
|
|
1699
961
|
if (!cur)
|
|
1700
|
-
throw new
|
|
962
|
+
throw new MhError("not_found", `no such property: ${id}`);
|
|
1701
963
|
if (!Number.isFinite(width))
|
|
1702
|
-
throw new
|
|
964
|
+
throw new MhError("invalid_input", "width must be a finite number");
|
|
1703
965
|
const w = Math.max(80, Math.min(2000, Math.round(width)));
|
|
1704
966
|
return updateProperty(db, id, { config: { ...cur.config ?? {}, width: w } });
|
|
1705
967
|
}
|
|
@@ -1716,6 +978,7 @@ function removeProperty(db, id) {
|
|
|
1716
978
|
|
|
1717
979
|
// src/core/resolve.ts
|
|
1718
980
|
init_ids();
|
|
981
|
+
init_errors();
|
|
1719
982
|
var SPECS = {
|
|
1720
983
|
db: { table: "databases", nameCol: "name", scoped: false, noun: "database" },
|
|
1721
984
|
prop: { table: "properties", nameCol: "name", scoped: true, noun: "property" },
|
|
@@ -1786,10 +1049,10 @@ function resolveEntity(db, ref, opts = {}) {
|
|
|
1786
1049
|
return cands[0];
|
|
1787
1050
|
if (cands.length === 0) {
|
|
1788
1051
|
const noun = opts.kind ? SPECS[opts.kind].noun : "item";
|
|
1789
|
-
throw new
|
|
1052
|
+
throw new MhError("not_found", `no such ${noun}: ${ref}`);
|
|
1790
1053
|
}
|
|
1791
1054
|
const shown = exact.length > 1 ? exact : cands;
|
|
1792
|
-
throw new
|
|
1055
|
+
throw new MhError("ambiguous", `ambiguous "${ref}" \u2014 ${shown.length} matches:
|
|
1793
1056
|
${fmt(shown)}
|
|
1794
1057
|
add more characters.`);
|
|
1795
1058
|
}
|
|
@@ -1854,6 +1117,7 @@ function keysBetween(a, b, n) {
|
|
|
1854
1117
|
}
|
|
1855
1118
|
|
|
1856
1119
|
// src/core/records.ts
|
|
1120
|
+
init_errors();
|
|
1857
1121
|
function isSqlScalar(v) {
|
|
1858
1122
|
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
1859
1123
|
}
|
|
@@ -1873,9 +1137,9 @@ function resolveRelation(db, prop, value) {
|
|
|
1873
1137
|
if (cands.length === 0) {
|
|
1874
1138
|
if (idKind(value) === "rec")
|
|
1875
1139
|
return value;
|
|
1876
|
-
throw new
|
|
1140
|
+
throw new MhError("not_found", `${prop.name}: no such record in target database: ${value}`);
|
|
1877
1141
|
}
|
|
1878
|
-
throw new
|
|
1142
|
+
throw new MhError("ambiguous", `${prop.name}: ambiguous relation "${value}" (${cands.length} matches); use a full id`);
|
|
1879
1143
|
}
|
|
1880
1144
|
function coerce(db, prop, value) {
|
|
1881
1145
|
switch (prop.type) {
|
|
@@ -1885,7 +1149,7 @@ function coerce(db, prop, value) {
|
|
|
1885
1149
|
if (value === null)
|
|
1886
1150
|
return null;
|
|
1887
1151
|
if (typeof value !== "string")
|
|
1888
|
-
throw new
|
|
1152
|
+
throw new MhError("invalid_input", `${prop.name} expects a string`);
|
|
1889
1153
|
return value;
|
|
1890
1154
|
}
|
|
1891
1155
|
case "number": {
|
|
@@ -1893,7 +1157,7 @@ function coerce(db, prop, value) {
|
|
|
1893
1157
|
return null;
|
|
1894
1158
|
const n = typeof value === "number" ? value : Number(value);
|
|
1895
1159
|
if (!Number.isFinite(n))
|
|
1896
|
-
throw new
|
|
1160
|
+
throw new MhError("invalid_input", `${prop.name} expects a number`);
|
|
1897
1161
|
return n;
|
|
1898
1162
|
}
|
|
1899
1163
|
case "checkbox": {
|
|
@@ -1903,14 +1167,14 @@ function coerce(db, prop, value) {
|
|
|
1903
1167
|
return true;
|
|
1904
1168
|
if (value === "false" || value === 0 || value === null)
|
|
1905
1169
|
return false;
|
|
1906
|
-
throw new
|
|
1170
|
+
throw new MhError("invalid_input", `${prop.name} expects a boolean`);
|
|
1907
1171
|
}
|
|
1908
1172
|
case "select": {
|
|
1909
1173
|
if (value === null)
|
|
1910
1174
|
return null;
|
|
1911
1175
|
const v = String(value);
|
|
1912
1176
|
if (!prop.config?.options?.includes(v))
|
|
1913
|
-
throw new
|
|
1177
|
+
throw new MhError("invalid_input", `${prop.name}: '${v}' is not an allowed option`);
|
|
1914
1178
|
return v;
|
|
1915
1179
|
}
|
|
1916
1180
|
case "multi_select": {
|
|
@@ -1918,7 +1182,7 @@ function coerce(db, prop, value) {
|
|
|
1918
1182
|
const opts = prop.config?.options ?? [];
|
|
1919
1183
|
for (const item of arr)
|
|
1920
1184
|
if (!opts.includes(String(item)))
|
|
1921
|
-
throw new
|
|
1185
|
+
throw new MhError("invalid_input", `${prop.name}: '${item}' is not an allowed option`);
|
|
1922
1186
|
return arr.map(String);
|
|
1923
1187
|
}
|
|
1924
1188
|
case "relation": {
|
|
@@ -1934,7 +1198,7 @@ function resolveData(props, data) {
|
|
|
1934
1198
|
for (const [key, value] of Object.entries(data)) {
|
|
1935
1199
|
const prop = byId.get(key) ?? byName.get(key.toLowerCase());
|
|
1936
1200
|
if (!prop)
|
|
1937
|
-
throw new
|
|
1201
|
+
throw new MhError("not_found", `unknown property: ${key}`);
|
|
1938
1202
|
out.push({ prop, value });
|
|
1939
1203
|
}
|
|
1940
1204
|
return out;
|
|
@@ -2003,7 +1267,7 @@ function backfillRecordOrderKeys(db, databaseId) {
|
|
|
2003
1267
|
function createRecord(db, databaseId, data) {
|
|
2004
1268
|
const dbRow = getDatabase(db, databaseId);
|
|
2005
1269
|
if (!dbRow)
|
|
2006
|
-
throw new
|
|
1270
|
+
throw new MhError("not_found", `no such database: ${databaseId}`);
|
|
2007
1271
|
const props = listProperties(db, databaseId);
|
|
2008
1272
|
const resolved = resolveData(props, data);
|
|
2009
1273
|
const id = deriveTitle(props, resolved, slugify(dbRow.name, "rec"));
|
|
@@ -2043,7 +1307,7 @@ function listRecords(db, databaseId, opts = {}) {
|
|
|
2043
1307
|
if (key !== "created" && key !== "created_hlc") {
|
|
2044
1308
|
const p = props.find((p2) => p2.name.toLowerCase() === key.toLowerCase() || p2.id === key);
|
|
2045
1309
|
if (!p)
|
|
2046
|
-
throw new
|
|
1310
|
+
throw new MhError("not_found", `unknown sort field: ${key}`);
|
|
2047
1311
|
sortProp = p;
|
|
2048
1312
|
}
|
|
2049
1313
|
}
|
|
@@ -2075,22 +1339,22 @@ function listRecords(db, databaseId, opts = {}) {
|
|
|
2075
1339
|
}
|
|
2076
1340
|
function moveRecord(db, id, targetId, where) {
|
|
2077
1341
|
if (where !== "before" && where !== "after")
|
|
2078
|
-
throw new
|
|
1342
|
+
throw new MhError("invalid_input", `unknown move position: ${where}`);
|
|
2079
1343
|
const src = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
2080
1344
|
if (!src)
|
|
2081
|
-
throw new
|
|
1345
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
2082
1346
|
const target = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(targetId);
|
|
2083
1347
|
if (!target)
|
|
2084
|
-
throw new
|
|
1348
|
+
throw new MhError("not_found", `no such record: ${targetId}`);
|
|
2085
1349
|
if (src.database_id !== target.database_id)
|
|
2086
|
-
throw new
|
|
1350
|
+
throw new MhError("invalid_input", "cannot move a record across databases");
|
|
2087
1351
|
if (id === targetId)
|
|
2088
1352
|
return getRecord(db, id);
|
|
2089
1353
|
backfillRecordOrderKeys(db, src.database_id);
|
|
2090
1354
|
let rows = orderedRecordRows(db, src.database_id).filter((r) => r.id !== id);
|
|
2091
1355
|
let to = rows.findIndex((r) => r.id === targetId);
|
|
2092
1356
|
if (to < 0)
|
|
2093
|
-
throw new
|
|
1357
|
+
throw new MhError("not_found", `no such target record: ${targetId}`);
|
|
2094
1358
|
let left = where === "before" ? rows[to - 1]?.order_key ?? null : rows[to].order_key;
|
|
2095
1359
|
let right = where === "before" ? rows[to].order_key : rows[to + 1]?.order_key ?? null;
|
|
2096
1360
|
if (left !== null && right !== null && left >= right) {
|
|
@@ -2106,7 +1370,7 @@ function moveRecord(db, id, targetId, where) {
|
|
|
2106
1370
|
function updateRecord(db, id, data) {
|
|
2107
1371
|
const rec = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
2108
1372
|
if (!rec)
|
|
2109
|
-
throw new
|
|
1373
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
2110
1374
|
const resolved = resolveData(listProperties(db, rec.database_id), data);
|
|
2111
1375
|
for (const { prop, value } of resolved)
|
|
2112
1376
|
emit(db, "records", id, prop.id, coerce(db, prop, value));
|
|
@@ -2120,120 +1384,24 @@ function deleteRecord(db, id) {
|
|
|
2120
1384
|
return true;
|
|
2121
1385
|
}
|
|
2122
1386
|
|
|
2123
|
-
// src/core/
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
1387
|
+
// src/core/documents.ts
|
|
1388
|
+
init_ids();
|
|
1389
|
+
init_crdt();
|
|
1390
|
+
init_errors();
|
|
1391
|
+
function liveBlocks(db, docId) {
|
|
1392
|
+
return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
2127
1393
|
}
|
|
2128
|
-
function
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
db.exec(FTS_SCHEMA);
|
|
2132
|
-
} catch {}
|
|
1394
|
+
function makeBlockId(text) {
|
|
1395
|
+
return newId("blk", text.split(`
|
|
1396
|
+
`, 1)[0] ?? "");
|
|
2133
1397
|
}
|
|
2134
|
-
function
|
|
2135
|
-
const
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
}
|
|
2142
|
-
function tableExists2(db, table) {
|
|
2143
|
-
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
2144
|
-
}
|
|
2145
|
-
function migrateRecords(db) {
|
|
2146
|
-
if (!hasColumn(db, "records", "data"))
|
|
2147
|
-
db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
|
|
2148
|
-
if (!hasColumn(db, "records", "order_key"))
|
|
2149
|
-
db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
|
|
2150
|
-
if (!tableExists2(db, "record_values"))
|
|
2151
|
-
return;
|
|
2152
|
-
const tx = db.transaction(() => {
|
|
2153
|
-
db.exec(`
|
|
2154
|
-
UPDATE records SET data = coalesce((
|
|
2155
|
-
SELECT json_group_object(rv.property_id, json(rv.value))
|
|
2156
|
-
FROM record_values rv WHERE rv.record_id = records.id
|
|
2157
|
-
), '{}')
|
|
2158
|
-
WHERE id IN (SELECT DISTINCT record_id FROM record_values);
|
|
2159
|
-
`);
|
|
2160
|
-
db.exec("DROP TABLE record_values");
|
|
2161
|
-
backfillRecordOrderKeys(db);
|
|
2162
|
-
});
|
|
2163
|
-
tx();
|
|
2164
|
-
backfillRecordOrderKeys(db);
|
|
2165
|
-
}
|
|
2166
|
-
function migratePeers(db) {
|
|
2167
|
-
const add = [
|
|
2168
|
-
["token", "TEXT"],
|
|
2169
|
-
["label", "TEXT"],
|
|
2170
|
-
["node_id", "TEXT"],
|
|
2171
|
-
["enabled", "INTEGER NOT NULL DEFAULT 1"],
|
|
2172
|
-
["last_sync_at", "INTEGER"],
|
|
2173
|
-
["last_status", "TEXT"],
|
|
2174
|
-
["last_error", "TEXT"]
|
|
2175
|
-
];
|
|
2176
|
-
for (const [col, decl] of add) {
|
|
2177
|
-
if (!hasColumn(db, "peers", col))
|
|
2178
|
-
db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
|
|
2179
|
-
}
|
|
2180
|
-
}
|
|
2181
|
-
function migrateDocBlocks(db) {
|
|
2182
|
-
if (!hasColumn(db, "doc_blocks", "blank_after"))
|
|
2183
|
-
db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
|
|
2184
|
-
}
|
|
2185
|
-
function openMetahub() {
|
|
2186
|
-
ensureDirs();
|
|
2187
|
-
const db = new Database(dbPath(), { create: true });
|
|
2188
|
-
db.exec("PRAGMA journal_mode = WAL;");
|
|
2189
|
-
runSchema(db);
|
|
2190
|
-
migrateRecords(db);
|
|
2191
|
-
migratePeers(db);
|
|
2192
|
-
migrateDocBlocks(db);
|
|
2193
|
-
return db;
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// src/core/index.ts
|
|
2197
|
-
init_ids();
|
|
2198
|
-
init_crdt();
|
|
2199
|
-
init_node();
|
|
2200
|
-
|
|
2201
|
-
// src/core/context.ts
|
|
2202
|
-
var KEY = "current_db";
|
|
2203
|
-
function getCurrentDatabase(db) {
|
|
2204
|
-
const row = db.query("SELECT value FROM meta WHERE key = ?").get(KEY);
|
|
2205
|
-
if (!row)
|
|
2206
|
-
return null;
|
|
2207
|
-
const found = getDatabase(db, row.value);
|
|
2208
|
-
if (!found) {
|
|
2209
|
-
clearCurrentDatabase(db);
|
|
2210
|
-
return null;
|
|
2211
|
-
}
|
|
2212
|
-
return found;
|
|
2213
|
-
}
|
|
2214
|
-
function setCurrentDatabase(db, id) {
|
|
2215
|
-
db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(KEY, id);
|
|
2216
|
-
}
|
|
2217
|
-
function clearCurrentDatabase(db) {
|
|
2218
|
-
db.query("DELETE FROM meta WHERE key = ?").run(KEY);
|
|
2219
|
-
}
|
|
2220
|
-
// src/core/documents.ts
|
|
2221
|
-
init_ids();
|
|
2222
|
-
init_crdt();
|
|
2223
|
-
function liveBlocks(db, docId) {
|
|
2224
|
-
return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
2225
|
-
}
|
|
2226
|
-
function makeBlockId(text) {
|
|
2227
|
-
return newId("blk", text.split(`
|
|
2228
|
-
`, 1)[0] ?? "");
|
|
2229
|
-
}
|
|
2230
|
-
function emitBlock(db, docId, fields) {
|
|
2231
|
-
const id = makeBlockId(fields.text);
|
|
2232
|
-
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
2233
|
-
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
2234
|
-
if (fields.blankAfter)
|
|
2235
|
-
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
2236
|
-
emit(db, "doc_blocks", id, "text", fields.text);
|
|
1398
|
+
function emitBlock(db, docId, fields) {
|
|
1399
|
+
const id = makeBlockId(fields.text);
|
|
1400
|
+
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
1401
|
+
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
1402
|
+
if (fields.blankAfter)
|
|
1403
|
+
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
1404
|
+
emit(db, "doc_blocks", id, "text", fields.text);
|
|
2237
1405
|
}
|
|
2238
1406
|
function insertBlocks(db, docId, blocks, after, before) {
|
|
2239
1407
|
const keys = keysBetween(after, before, blocks.length);
|
|
@@ -2288,6 +1456,7 @@ function createDocument(db, opts) {
|
|
|
2288
1456
|
emit(db, "documents", id, "database_id", opts.database_id);
|
|
2289
1457
|
if (opts.parent_id !== undefined)
|
|
2290
1458
|
emit(db, "documents", id, "parent_id", opts.parent_id);
|
|
1459
|
+
placeInSiblings(db, id, opts.parent_id ?? null);
|
|
2291
1460
|
if (opts.body !== undefined) {
|
|
2292
1461
|
const blocks = parseDocBlocks(opts.body);
|
|
2293
1462
|
if (blocks.length)
|
|
@@ -2296,31 +1465,125 @@ function createDocument(db, opts) {
|
|
|
2296
1465
|
return getDocument(db, id);
|
|
2297
1466
|
}
|
|
2298
1467
|
function getDocument(db, id) {
|
|
2299
|
-
return db.query("SELECT id, title, body, database_id, parent_id, created_hlc FROM documents WHERE id = ? AND __deleted = 0").get(id);
|
|
1468
|
+
return db.query("SELECT id, title, body, database_id, parent_id, created_hlc, order_key FROM documents WHERE id = ? AND __deleted = 0").get(id);
|
|
2300
1469
|
}
|
|
1470
|
+
var ORDER_BY = "ORDER BY order_key IS NULL, order_key, created_hlc, id";
|
|
2301
1471
|
function listDocuments(db, opts = {}) {
|
|
2302
|
-
const cols = "SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0";
|
|
1472
|
+
const cols = "SELECT id, title, database_id, parent_id, created_hlc, order_key FROM documents WHERE __deleted = 0";
|
|
2303
1473
|
if (opts.parent_id !== undefined)
|
|
2304
|
-
return db.query(`${cols} AND parent_id = ?
|
|
1474
|
+
return db.query(`${cols} AND parent_id = ? ${ORDER_BY}`).all(opts.parent_id);
|
|
2305
1475
|
if (opts.database_id)
|
|
2306
|
-
return db.query(`${cols} AND database_id = ?
|
|
2307
|
-
return db.query(`${cols}
|
|
1476
|
+
return db.query(`${cols} AND database_id = ? ${ORDER_BY}`).all(opts.database_id);
|
|
1477
|
+
return db.query(`${cols} ${ORDER_BY}`).all();
|
|
1478
|
+
}
|
|
1479
|
+
function siblingWhere(parentId) {
|
|
1480
|
+
return parentId === null ? { clause: "parent_id IS NULL", args: [] } : { clause: "parent_id = ?", args: [parentId] };
|
|
1481
|
+
}
|
|
1482
|
+
function canEmitOrderKeys2(db) {
|
|
1483
|
+
return tableExists2(db, "meta") && tableExists2(db, "crdt_changes");
|
|
1484
|
+
}
|
|
1485
|
+
function tableExists2(db, table) {
|
|
1486
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
1487
|
+
}
|
|
1488
|
+
function lastSiblingOrderKey(db, parentId, excludeId) {
|
|
1489
|
+
const w = siblingWhere(parentId);
|
|
1490
|
+
const exclude = excludeId ? " AND id <> ?" : "";
|
|
1491
|
+
const row = db.query(`SELECT order_key FROM documents WHERE ${w.clause}${exclude}
|
|
1492
|
+
AND __deleted = 0 AND order_key IS NOT NULL
|
|
1493
|
+
ORDER BY order_key DESC, id DESC LIMIT 1`).get(...w.args, ...excludeId ? [excludeId] : []);
|
|
1494
|
+
return row?.order_key ?? null;
|
|
1495
|
+
}
|
|
1496
|
+
function orderedSiblings(db, parentId) {
|
|
1497
|
+
const w = siblingWhere(parentId);
|
|
1498
|
+
return db.query(`SELECT id, order_key FROM documents WHERE ${w.clause} AND __deleted = 0 ${ORDER_BY}`).all(...w.args);
|
|
1499
|
+
}
|
|
1500
|
+
function rebalanceSiblings(db, parentId) {
|
|
1501
|
+
const rows = orderedSiblings(db, parentId);
|
|
1502
|
+
const keys = keysBetween(null, null, rows.length);
|
|
1503
|
+
rows.forEach((row, i) => {
|
|
1504
|
+
if (row.order_key !== keys[i])
|
|
1505
|
+
emit(db, "documents", row.id, "order_key", keys[i]);
|
|
1506
|
+
});
|
|
2308
1507
|
}
|
|
2309
|
-
function
|
|
1508
|
+
function backfillSiblings(db, parentId) {
|
|
1509
|
+
const emitChange = canEmitOrderKeys2(db);
|
|
1510
|
+
const w = siblingWhere(parentId);
|
|
1511
|
+
const missing = db.query(`SELECT id FROM documents WHERE ${w.clause} AND __deleted = 0 AND order_key IS NULL
|
|
1512
|
+
ORDER BY created_hlc, id`).all(...w.args);
|
|
1513
|
+
if (!missing.length)
|
|
1514
|
+
return;
|
|
1515
|
+
const keys = keysBetween(lastSiblingOrderKey(db, parentId), null, missing.length);
|
|
1516
|
+
missing.forEach((row, i) => {
|
|
1517
|
+
if (emitChange)
|
|
1518
|
+
emit(db, "documents", row.id, "order_key", keys[i]);
|
|
1519
|
+
else
|
|
1520
|
+
db.query("UPDATE documents SET order_key = ? WHERE id = ?").run(keys[i], row.id);
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
function backfillDocumentOrderKeys(db) {
|
|
1524
|
+
const parents = db.query("SELECT DISTINCT parent_id FROM documents WHERE __deleted = 0 AND order_key IS NULL").all();
|
|
1525
|
+
for (const p of parents)
|
|
1526
|
+
backfillSiblings(db, p.parent_id);
|
|
1527
|
+
}
|
|
1528
|
+
function placeInSiblings(db, id, parentId, anchor) {
|
|
1529
|
+
let cur = parentId;
|
|
1530
|
+
while (cur) {
|
|
1531
|
+
if (cur === id)
|
|
1532
|
+
throw new MhError("invalid_input", `cannot set parent_id: would create a cycle (${id})`);
|
|
1533
|
+
cur = getDocument(db, cur)?.parent_id ?? null;
|
|
1534
|
+
}
|
|
1535
|
+
const current = getDocument(db, id);
|
|
1536
|
+
if (current && (current.parent_id ?? null) !== parentId)
|
|
1537
|
+
emit(db, "documents", id, "parent_id", parentId);
|
|
1538
|
+
let key;
|
|
1539
|
+
if (!anchor) {
|
|
1540
|
+
key = keyBetween(lastSiblingOrderKey(db, parentId, id), null);
|
|
1541
|
+
} else {
|
|
1542
|
+
backfillSiblings(db, parentId);
|
|
1543
|
+
const neighbors = () => {
|
|
1544
|
+
const rows = orderedSiblings(db, parentId).filter((r) => r.id !== id);
|
|
1545
|
+
const to = rows.findIndex((r) => r.id === anchor.targetId);
|
|
1546
|
+
if (to < 0)
|
|
1547
|
+
throw new MhError("not_found", `no such target document: ${anchor.targetId}`);
|
|
1548
|
+
return anchor.where === "before" ? [rows[to - 1]?.order_key ?? null, rows[to].order_key] : [rows[to].order_key, rows[to + 1]?.order_key ?? null];
|
|
1549
|
+
};
|
|
1550
|
+
let [left, right] = neighbors();
|
|
1551
|
+
if (left !== null && right !== null && left >= right) {
|
|
1552
|
+
rebalanceSiblings(db, parentId);
|
|
1553
|
+
[left, right] = neighbors();
|
|
1554
|
+
}
|
|
1555
|
+
key = keyBetween(left, right);
|
|
1556
|
+
}
|
|
1557
|
+
emit(db, "documents", id, "order_key", key);
|
|
1558
|
+
}
|
|
1559
|
+
function moveDocument(db, id, targetId, where) {
|
|
1560
|
+
const src = getDocument(db, id);
|
|
1561
|
+
if (!src)
|
|
1562
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
1563
|
+
if (id === targetId)
|
|
1564
|
+
return src;
|
|
1565
|
+
const target = getDocument(db, targetId);
|
|
1566
|
+
if (!target)
|
|
1567
|
+
throw new MhError("not_found", `no such target document: ${targetId}`);
|
|
1568
|
+
if (where === "into")
|
|
1569
|
+
placeInSiblings(db, id, targetId);
|
|
1570
|
+
else
|
|
1571
|
+
placeInSiblings(db, id, target.parent_id, { targetId, where });
|
|
1572
|
+
return getDocument(db, id);
|
|
1573
|
+
}
|
|
1574
|
+
function updateDocument(db, id, fields, opts = {}) {
|
|
2310
1575
|
if (!getDocument(db, id))
|
|
2311
|
-
throw new
|
|
1576
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
1577
|
+
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
1578
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
2312
1579
|
if (fields.title !== undefined)
|
|
2313
1580
|
emit(db, "documents", id, "title", fields.title);
|
|
2314
1581
|
if (fields.database_id !== undefined)
|
|
2315
1582
|
emit(db, "documents", id, "database_id", fields.database_id);
|
|
2316
1583
|
if (fields.parent_id !== undefined) {
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
throw new Error(`cannot set parent_id: would create a cycle (${id})`);
|
|
2321
|
-
cur = getDocument(db, cur)?.parent_id ?? null;
|
|
2322
|
-
}
|
|
2323
|
-
emit(db, "documents", id, "parent_id", fields.parent_id);
|
|
1584
|
+
const next = fields.parent_id ?? null;
|
|
1585
|
+
if ((getDocument(db, id).parent_id ?? null) !== next)
|
|
1586
|
+
placeInSiblings(db, id, next);
|
|
2324
1587
|
}
|
|
2325
1588
|
if (fields.body !== undefined) {
|
|
2326
1589
|
ensureBlocks(db, id);
|
|
@@ -2330,19 +1593,19 @@ function updateDocument(db, id, fields) {
|
|
|
2330
1593
|
}
|
|
2331
1594
|
function editDocument(db, id, opts) {
|
|
2332
1595
|
if (!getDocument(db, id))
|
|
2333
|
-
throw new
|
|
1596
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2334
1597
|
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
2335
|
-
throw new
|
|
1598
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
2336
1599
|
if (opts.old === "")
|
|
2337
|
-
throw new
|
|
1600
|
+
throw new MhError("invalid_input", "--old must not be empty");
|
|
2338
1601
|
ensureBlocks(db, id);
|
|
2339
1602
|
const blocks = liveBlocks(db, id);
|
|
2340
1603
|
const body = serializeDocBlocks(blocks.map((b) => ({ text: b.text, blankAfter: b.blank_after })));
|
|
2341
1604
|
const count = countOccurrences(body, opts.old);
|
|
2342
1605
|
if (count === 0)
|
|
2343
|
-
throw new
|
|
1606
|
+
throw new MhError("invalid_input", `anchor not found: no match for --old`);
|
|
2344
1607
|
if (count > 1 && !opts.replaceAll)
|
|
2345
|
-
throw new
|
|
1608
|
+
throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
|
|
2346
1609
|
if (!opts.old.includes(`
|
|
2347
1610
|
|
|
2348
1611
|
`)) {
|
|
@@ -2362,7 +1625,7 @@ function editDocument(db, id, opts) {
|
|
|
2362
1625
|
}
|
|
2363
1626
|
function appendDocument(db, id, body) {
|
|
2364
1627
|
if (!getDocument(db, id))
|
|
2365
|
-
throw new
|
|
1628
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2366
1629
|
ensureBlocks(db, id);
|
|
2367
1630
|
const next = parseDocBlocks(body);
|
|
2368
1631
|
if (next.length) {
|
|
@@ -2374,7 +1637,7 @@ function appendDocument(db, id, body) {
|
|
|
2374
1637
|
}
|
|
2375
1638
|
function prependDocument(db, id, body) {
|
|
2376
1639
|
if (!getDocument(db, id))
|
|
2377
|
-
throw new
|
|
1640
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2378
1641
|
ensureBlocks(db, id);
|
|
2379
1642
|
const next = parseDocBlocks(body);
|
|
2380
1643
|
if (next.length) {
|
|
@@ -2408,6 +1671,110 @@ function countOccurrences(haystack, needle) {
|
|
|
2408
1671
|
}
|
|
2409
1672
|
return n;
|
|
2410
1673
|
}
|
|
1674
|
+
|
|
1675
|
+
// src/core/db.ts
|
|
1676
|
+
function ensureDirs() {
|
|
1677
|
+
mkdirSync(metahubHome(), { recursive: true });
|
|
1678
|
+
mkdirSync(cacheDir(), { recursive: true });
|
|
1679
|
+
}
|
|
1680
|
+
function runSchema(db) {
|
|
1681
|
+
db.exec(CORE_SCHEMA);
|
|
1682
|
+
try {
|
|
1683
|
+
db.exec(FTS_SCHEMA);
|
|
1684
|
+
} catch {}
|
|
1685
|
+
}
|
|
1686
|
+
function ftsAvailable(db) {
|
|
1687
|
+
const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE name = 'search_fts'").get();
|
|
1688
|
+
return row != null;
|
|
1689
|
+
}
|
|
1690
|
+
function hasColumn(db, table, column) {
|
|
1691
|
+
const cols = db.query(`PRAGMA table_info(${table})`).all();
|
|
1692
|
+
return cols.some((c) => c.name === column);
|
|
1693
|
+
}
|
|
1694
|
+
function tableExists3(db, table) {
|
|
1695
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
1696
|
+
}
|
|
1697
|
+
function migrateRecords(db) {
|
|
1698
|
+
if (!hasColumn(db, "records", "data"))
|
|
1699
|
+
db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
|
|
1700
|
+
if (!hasColumn(db, "records", "order_key"))
|
|
1701
|
+
db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
|
|
1702
|
+
if (!tableExists3(db, "record_values"))
|
|
1703
|
+
return;
|
|
1704
|
+
const tx = db.transaction(() => {
|
|
1705
|
+
db.exec(`
|
|
1706
|
+
UPDATE records SET data = coalesce((
|
|
1707
|
+
SELECT json_group_object(rv.property_id, json(rv.value))
|
|
1708
|
+
FROM record_values rv WHERE rv.record_id = records.id
|
|
1709
|
+
), '{}')
|
|
1710
|
+
WHERE id IN (SELECT DISTINCT record_id FROM record_values);
|
|
1711
|
+
`);
|
|
1712
|
+
db.exec("DROP TABLE record_values");
|
|
1713
|
+
backfillRecordOrderKeys(db);
|
|
1714
|
+
});
|
|
1715
|
+
tx();
|
|
1716
|
+
backfillRecordOrderKeys(db);
|
|
1717
|
+
}
|
|
1718
|
+
function migratePeers(db) {
|
|
1719
|
+
const add = [
|
|
1720
|
+
["token", "TEXT"],
|
|
1721
|
+
["label", "TEXT"],
|
|
1722
|
+
["node_id", "TEXT"],
|
|
1723
|
+
["enabled", "INTEGER NOT NULL DEFAULT 1"],
|
|
1724
|
+
["last_sync_at", "INTEGER"],
|
|
1725
|
+
["last_status", "TEXT"],
|
|
1726
|
+
["last_error", "TEXT"]
|
|
1727
|
+
];
|
|
1728
|
+
for (const [col, decl] of add) {
|
|
1729
|
+
if (!hasColumn(db, "peers", col))
|
|
1730
|
+
db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
function migrateDocBlocks(db) {
|
|
1734
|
+
if (!hasColumn(db, "doc_blocks", "blank_after"))
|
|
1735
|
+
db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
|
|
1736
|
+
}
|
|
1737
|
+
function migrateDocuments(db) {
|
|
1738
|
+
if (!hasColumn(db, "documents", "order_key"))
|
|
1739
|
+
db.exec("ALTER TABLE documents ADD COLUMN order_key TEXT");
|
|
1740
|
+
backfillDocumentOrderKeys(db);
|
|
1741
|
+
}
|
|
1742
|
+
function openMetahub() {
|
|
1743
|
+
ensureDirs();
|
|
1744
|
+
const db = new Database(dbPath(), { create: true });
|
|
1745
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
1746
|
+
runSchema(db);
|
|
1747
|
+
migrateRecords(db);
|
|
1748
|
+
migratePeers(db);
|
|
1749
|
+
migrateDocBlocks(db);
|
|
1750
|
+
migrateDocuments(db);
|
|
1751
|
+
return db;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// src/core/index.ts
|
|
1755
|
+
init_ids();
|
|
1756
|
+
init_crdt();
|
|
1757
|
+
init_node();
|
|
1758
|
+
|
|
1759
|
+
// src/core/context.ts
|
|
1760
|
+
var KEY = "current_db";
|
|
1761
|
+
function getCurrentDatabase(db) {
|
|
1762
|
+
const row = db.query("SELECT value FROM meta WHERE key = ?").get(KEY);
|
|
1763
|
+
if (!row)
|
|
1764
|
+
return null;
|
|
1765
|
+
const found = getDatabase(db, row.value);
|
|
1766
|
+
if (!found) {
|
|
1767
|
+
clearCurrentDatabase(db);
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
return found;
|
|
1771
|
+
}
|
|
1772
|
+
function setCurrentDatabase(db, id) {
|
|
1773
|
+
db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(KEY, id);
|
|
1774
|
+
}
|
|
1775
|
+
function clearCurrentDatabase(db) {
|
|
1776
|
+
db.query("DELETE FROM meta WHERE key = ?").run(KEY);
|
|
1777
|
+
}
|
|
2411
1778
|
// src/core/search.ts
|
|
2412
1779
|
init_crdt();
|
|
2413
1780
|
var TEXT_TYPES = "('text','url','select','multi_select','date')";
|
|
@@ -16897,6 +16264,7 @@ var PAIR_PATH = "/api/pair";
|
|
|
16897
16264
|
// src/core/sync/client.ts
|
|
16898
16265
|
init_node();
|
|
16899
16266
|
init_crdt();
|
|
16267
|
+
init_errors();
|
|
16900
16268
|
function getPeer(db, url2) {
|
|
16901
16269
|
const row = db.query("SELECT pull_cursor, push_cursor, token FROM peers WHERE url = ?").get(url2);
|
|
16902
16270
|
return row ?? { pull_cursor: 0, push_cursor: 0, token: null };
|
|
@@ -16918,7 +16286,7 @@ async function syncWithPeer(db, url2, token) {
|
|
|
16918
16286
|
body: JSON.stringify({ node_id: node, since: peer.pull_cursor, changes: toPush.changes })
|
|
16919
16287
|
});
|
|
16920
16288
|
if (!res.ok)
|
|
16921
|
-
throw new
|
|
16289
|
+
throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `sync failed: ${res.status} ${await res.text()}`);
|
|
16922
16290
|
const data = await res.json();
|
|
16923
16291
|
const pulled = data.changes?.length ?? 0;
|
|
16924
16292
|
ingest(db, data.changes ?? []);
|
|
@@ -16926,6 +16294,7 @@ async function syncWithPeer(db, url2, token) {
|
|
|
16926
16294
|
return { pushed: toPush.changes.length, pulled };
|
|
16927
16295
|
}
|
|
16928
16296
|
// src/core/sync/server.ts
|
|
16297
|
+
init_errors();
|
|
16929
16298
|
init_node();
|
|
16930
16299
|
|
|
16931
16300
|
// src/core/sync/token.ts
|
|
@@ -17028,7 +16397,7 @@ function getServerConfig(db) {
|
|
|
17028
16397
|
// package.json
|
|
17029
16398
|
var package_default = {
|
|
17030
16399
|
name: "@tensorix/metahub",
|
|
17031
|
-
version: "0.1.
|
|
16400
|
+
version: "0.1.10",
|
|
17032
16401
|
description: "Metahub core library and CLI",
|
|
17033
16402
|
license: "AGPL-3.0-only",
|
|
17034
16403
|
type: "module",
|
|
@@ -17079,356 +16448,113 @@ var package_default = {
|
|
|
17079
16448
|
// src/core/sync/routes.ts
|
|
17080
16449
|
init_crdt();
|
|
17081
16450
|
|
|
17082
|
-
// src/core/sync/
|
|
17083
|
-
|
|
16451
|
+
// src/core/sync/sites-routes.ts
|
|
16452
|
+
init_errors();
|
|
16453
|
+
init_sites();
|
|
16454
|
+
var SiteSchema = exports_external.object({
|
|
17084
16455
|
id: exports_external.string(),
|
|
17085
16456
|
name: exports_external.string(),
|
|
17086
|
-
|
|
16457
|
+
title: exports_external.string().nullable(),
|
|
17087
16458
|
created_hlc: exports_external.string()
|
|
17088
16459
|
});
|
|
17089
|
-
var
|
|
17090
|
-
|
|
17091
|
-
database_id: exports_external.string(),
|
|
17092
|
-
name: exports_external.string(),
|
|
17093
|
-
type: exports_external.string(),
|
|
17094
|
-
config: exports_external.any().nullable(),
|
|
17095
|
-
position: exports_external.number()
|
|
17096
|
-
});
|
|
17097
|
-
var RecordSchema = exports_external.object({
|
|
16460
|
+
var SiteWithCountSchema = SiteSchema.extend({ file_count: exports_external.number() });
|
|
16461
|
+
var SiteFileSchema = exports_external.object({
|
|
17098
16462
|
id: exports_external.string(),
|
|
17099
|
-
|
|
17100
|
-
|
|
17101
|
-
|
|
17102
|
-
|
|
17103
|
-
target: exports_external.string(),
|
|
17104
|
-
where: exports_external.enum(["before", "after"])
|
|
17105
|
-
});
|
|
17106
|
-
var DocumentSummarySchema = exports_external.object({
|
|
17107
|
-
id: exports_external.string(),
|
|
17108
|
-
title: exports_external.string(),
|
|
17109
|
-
database_id: exports_external.string().nullable(),
|
|
17110
|
-
parent_id: exports_external.string().nullable(),
|
|
17111
|
-
created_hlc: exports_external.string()
|
|
17112
|
-
});
|
|
17113
|
-
var DocumentSchema = DocumentSummarySchema.extend({ body: exports_external.string().nullable() });
|
|
17114
|
-
var SearchHitSchema = exports_external.object({
|
|
17115
|
-
type: exports_external.string(),
|
|
17116
|
-
id: exports_external.string(),
|
|
17117
|
-
database_id: exports_external.string().nullable(),
|
|
17118
|
-
title: exports_external.string().optional(),
|
|
17119
|
-
snippet: exports_external.string()
|
|
17120
|
-
});
|
|
17121
|
-
var OkSchema = exports_external.object({ ok: exports_external.boolean() });
|
|
17122
|
-
var CreateDatabaseReq = exports_external.object({ name: exports_external.string(), icon: exports_external.string().optional() });
|
|
17123
|
-
var UpdateDatabaseReq = exports_external.object({
|
|
17124
|
-
name: exports_external.string().optional(),
|
|
17125
|
-
icon: exports_external.string().nullable().optional()
|
|
17126
|
-
});
|
|
17127
|
-
var CreatePropertyReq = exports_external.object({
|
|
17128
|
-
db: exports_external.string(),
|
|
17129
|
-
name: exports_external.string(),
|
|
17130
|
-
type: exports_external.string(),
|
|
17131
|
-
config: exports_external.any().optional()
|
|
17132
|
-
});
|
|
17133
|
-
var UpdatePropertyReq = exports_external.object({
|
|
17134
|
-
name: exports_external.string().optional(),
|
|
17135
|
-
type: exports_external.string().optional(),
|
|
17136
|
-
config: exports_external.any().optional(),
|
|
17137
|
-
position: exports_external.number().optional()
|
|
17138
|
-
});
|
|
17139
|
-
var SetWidthReq = exports_external.object({ width: exports_external.number() });
|
|
17140
|
-
var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any());
|
|
17141
|
-
var CreateDocumentReq = exports_external.object({
|
|
17142
|
-
title: exports_external.string(),
|
|
17143
|
-
body: exports_external.string().optional(),
|
|
17144
|
-
database_id: exports_external.string().optional(),
|
|
17145
|
-
parent_id: exports_external.string().optional()
|
|
17146
|
-
});
|
|
17147
|
-
var UpdateDocumentReq = exports_external.object({
|
|
17148
|
-
title: exports_external.string().optional(),
|
|
17149
|
-
body: exports_external.string().optional(),
|
|
17150
|
-
parent_id: exports_external.string().nullable().optional()
|
|
16463
|
+
site_id: exports_external.string(),
|
|
16464
|
+
path: exports_external.string(),
|
|
16465
|
+
content_type: exports_external.string(),
|
|
16466
|
+
encoding: exports_external.string()
|
|
17151
16467
|
});
|
|
16468
|
+
var CreateSiteBody = exports_external.object({ name: exports_external.string(), title: exports_external.string().optional() });
|
|
16469
|
+
var UpdateSiteBody = exports_external.object({ name: exports_external.string().optional(), title: exports_external.string().optional() });
|
|
17152
16470
|
function need(req, key) {
|
|
17153
16471
|
const v = new URL(req.url).searchParams.get(key);
|
|
17154
16472
|
if (!v)
|
|
17155
|
-
throw new
|
|
16473
|
+
throw new MhError("invalid_input", `missing query param: ${key}`);
|
|
17156
16474
|
return v;
|
|
17157
16475
|
}
|
|
17158
|
-
function opt(req, key) {
|
|
17159
|
-
return new URL(req.url).searchParams.get(key) ?? undefined;
|
|
17160
|
-
}
|
|
17161
16476
|
function handle(fn) {
|
|
17162
16477
|
return async (req, ctx) => {
|
|
17163
16478
|
try {
|
|
17164
|
-
|
|
17165
|
-
if (out instanceof Response)
|
|
17166
|
-
return out;
|
|
17167
|
-
return Response.json(out ?? null);
|
|
16479
|
+
return Response.json(await fn(req, ctx) ?? null);
|
|
17168
16480
|
} catch (e) {
|
|
17169
|
-
return
|
|
16481
|
+
return errorResponse(e);
|
|
17170
16482
|
}
|
|
17171
16483
|
};
|
|
17172
16484
|
}
|
|
17173
|
-
var
|
|
17174
|
-
var webuiRoutes = [
|
|
16485
|
+
var sitesRoutes = [
|
|
17175
16486
|
{
|
|
17176
16487
|
method: "GET",
|
|
17177
|
-
path: "/api/
|
|
17178
|
-
summary: "
|
|
17179
|
-
response:
|
|
17180
|
-
handler: () =>
|
|
16488
|
+
path: "/api/sites",
|
|
16489
|
+
summary: "List published sites (with file counts)",
|
|
16490
|
+
response: exports_external.array(SiteWithCountSchema),
|
|
16491
|
+
handler: handle((_req, { db }) => listSites(db).map((s) => ({ ...s, file_count: listFiles(db, s.id).length })))
|
|
17181
16492
|
},
|
|
17182
16493
|
{
|
|
17183
16494
|
method: "GET",
|
|
17184
|
-
path: "/api/
|
|
17185
|
-
summary: "List
|
|
17186
|
-
response: exports_external.array(
|
|
17187
|
-
handler: handle((
|
|
16495
|
+
path: "/api/site/files",
|
|
16496
|
+
summary: "List a site's files (manifest). Query: ?site=<id|name>",
|
|
16497
|
+
response: exports_external.array(SiteFileSchema),
|
|
16498
|
+
handler: handle((req, { db }) => listFiles(db, resolveSite(db, need(req, "site")).id))
|
|
17188
16499
|
},
|
|
17189
16500
|
{
|
|
17190
16501
|
method: "POST",
|
|
17191
|
-
path: "/api/
|
|
17192
|
-
summary: "Create a
|
|
17193
|
-
request:
|
|
17194
|
-
response:
|
|
16502
|
+
path: "/api/sites",
|
|
16503
|
+
summary: "Create a site",
|
|
16504
|
+
request: CreateSiteBody,
|
|
16505
|
+
response: SiteSchema,
|
|
17195
16506
|
handler: handle(async (req, { db }) => {
|
|
17196
|
-
const body = await req.json();
|
|
17197
|
-
return
|
|
16507
|
+
const body = CreateSiteBody.parse(await req.json());
|
|
16508
|
+
return createSite(db, body);
|
|
17198
16509
|
})
|
|
17199
16510
|
},
|
|
17200
16511
|
{
|
|
17201
16512
|
method: "PATCH",
|
|
17202
|
-
path: "/api/
|
|
17203
|
-
summary: "Rename a
|
|
17204
|
-
request:
|
|
17205
|
-
response:
|
|
16513
|
+
path: "/api/site",
|
|
16514
|
+
summary: "Rename a site or change its title. Query: ?id=<id>",
|
|
16515
|
+
request: UpdateSiteBody,
|
|
16516
|
+
response: SiteSchema,
|
|
17206
16517
|
handler: handle(async (req, { db }) => {
|
|
17207
|
-
const body = await req.json();
|
|
17208
|
-
return
|
|
16518
|
+
const body = UpdateSiteBody.parse(await req.json());
|
|
16519
|
+
return updateSite(db, need(req, "id"), body);
|
|
17209
16520
|
})
|
|
17210
16521
|
},
|
|
17211
16522
|
{
|
|
17212
16523
|
method: "DELETE",
|
|
17213
|
-
path: "/api/
|
|
17214
|
-
summary: "Delete a
|
|
17215
|
-
response:
|
|
17216
|
-
handler: handle((req, { db }) => ({ ok:
|
|
17217
|
-
},
|
|
17218
|
-
{
|
|
17219
|
-
method: "GET",
|
|
17220
|
-
path: "/api/properties",
|
|
17221
|
-
summary: "List a database's properties (columns). Query: ?db=<id>",
|
|
17222
|
-
response: exports_external.array(PropertySchema),
|
|
17223
|
-
handler: handle((req, { db }) => listProperties(db, need(req, "db")))
|
|
16524
|
+
path: "/api/site",
|
|
16525
|
+
summary: "Delete a site and its files. Query: ?id=<id>",
|
|
16526
|
+
response: exports_external.object({ ok: exports_external.boolean() }),
|
|
16527
|
+
handler: handle((req, { db }) => ({ ok: deleteSite(db, need(req, "id")) }))
|
|
17224
16528
|
},
|
|
17225
16529
|
{
|
|
17226
16530
|
method: "POST",
|
|
17227
|
-
path: "/api/
|
|
17228
|
-
summary: "
|
|
17229
|
-
|
|
17230
|
-
response: PropertySchema,
|
|
17231
|
-
handler: handle(async (req, { db }) => {
|
|
17232
|
-
const body = await req.json();
|
|
17233
|
-
return addProperty(db, body.db, { name: body.name, type: body.type, config: body.config });
|
|
17234
|
-
})
|
|
17235
|
-
},
|
|
17236
|
-
{
|
|
17237
|
-
method: "PATCH",
|
|
17238
|
-
path: "/api/property",
|
|
17239
|
-
summary: "Update a property: rename, change type, edit config/options, reorder. Query: ?id=<id>",
|
|
17240
|
-
request: UpdatePropertyReq,
|
|
17241
|
-
response: PropertySchema,
|
|
17242
|
-
handler: handle(async (req, { db }) => {
|
|
17243
|
-
const body = await req.json();
|
|
17244
|
-
return updateProperty(db, need(req, "id"), body);
|
|
17245
|
-
})
|
|
17246
|
-
},
|
|
17247
|
-
{
|
|
17248
|
-
method: "PATCH",
|
|
17249
|
-
path: "/api/property/width",
|
|
17250
|
-
summary: "Set a property's table column width (px). Query: ?id=<id>",
|
|
17251
|
-
request: SetWidthReq,
|
|
17252
|
-
response: PropertySchema,
|
|
16531
|
+
path: "/api/site/file",
|
|
16532
|
+
summary: "Upload/replace a file. Query: ?site=<id|name>&path=<path>; body is raw bytes",
|
|
16533
|
+
response: SiteFileSchema,
|
|
17253
16534
|
handler: handle(async (req, { db }) => {
|
|
17254
|
-
const
|
|
17255
|
-
|
|
17256
|
-
|
|
17257
|
-
|
|
17258
|
-
|
|
17259
|
-
method: "DELETE",
|
|
17260
|
-
path: "/api/property",
|
|
17261
|
-
summary: "Delete a property (column) and its cells. Query: ?id=<id>",
|
|
17262
|
-
response: OkSchema,
|
|
17263
|
-
handler: handle((req, { db }) => ({ ok: removeProperty(db, need(req, "id")) }))
|
|
17264
|
-
},
|
|
17265
|
-
{
|
|
17266
|
-
method: "GET",
|
|
17267
|
-
path: "/api/records",
|
|
17268
|
-
summary: "List records in a database. Query: ?db=<id>&sort=<field>&limit=<n>",
|
|
17269
|
-
response: exports_external.array(RecordSchema),
|
|
17270
|
-
handler: handle((req, { db }) => {
|
|
17271
|
-
const limit = opt(req, "limit");
|
|
17272
|
-
return listRecords(db, need(req, "db"), {
|
|
17273
|
-
sort: opt(req, "sort"),
|
|
17274
|
-
limit: limit ? Number(limit) : undefined
|
|
16535
|
+
const site = resolveSite(db, need(req, "site"));
|
|
16536
|
+
const ct = req.headers.get("content-type");
|
|
16537
|
+
const file2 = await putFile(db, site.id, need(req, "path"), {
|
|
16538
|
+
data: await req.arrayBuffer(),
|
|
16539
|
+
contentType: ct && ct !== "application/octet-stream" ? ct : undefined
|
|
17275
16540
|
});
|
|
17276
|
-
|
|
17277
|
-
|
|
17278
|
-
{
|
|
17279
|
-
method: "POST",
|
|
17280
|
-
path: "/api/records",
|
|
17281
|
-
summary: "Create a record. Query: ?db=<id>; body is a {field: value} map",
|
|
17282
|
-
request: RecordValuesReq,
|
|
17283
|
-
response: RecordSchema,
|
|
17284
|
-
handler: handle(async (req, { db }) => {
|
|
17285
|
-
const values = await req.json();
|
|
17286
|
-
return createRecord(db, need(req, "db"), values);
|
|
17287
|
-
})
|
|
17288
|
-
},
|
|
17289
|
-
{
|
|
17290
|
-
method: "GET",
|
|
17291
|
-
path: "/api/record",
|
|
17292
|
-
summary: "Get one record. Query: ?id=<id>",
|
|
17293
|
-
response: RecordSchema,
|
|
17294
|
-
handler: handle((req, { db }) => {
|
|
17295
|
-
const rec = getRecord(db, need(req, "id"));
|
|
17296
|
-
return rec ?? new Response("not found", { status: 404 });
|
|
17297
|
-
})
|
|
17298
|
-
},
|
|
17299
|
-
{
|
|
17300
|
-
method: "PATCH",
|
|
17301
|
-
path: "/api/record",
|
|
17302
|
-
summary: "Update record fields. Query: ?id=<id>; body is a {field: value} map",
|
|
17303
|
-
request: RecordValuesReq,
|
|
17304
|
-
response: RecordSchema,
|
|
17305
|
-
handler: handle(async (req, { db }) => {
|
|
17306
|
-
const values = await req.json();
|
|
17307
|
-
return updateRecord(db, need(req, "id"), values);
|
|
17308
|
-
})
|
|
17309
|
-
},
|
|
17310
|
-
{
|
|
17311
|
-
method: "PATCH",
|
|
17312
|
-
path: "/api/record/order",
|
|
17313
|
-
summary: "Move a record before or after another record. Query: ?id=<id>",
|
|
17314
|
-
request: MoveRecordReq,
|
|
17315
|
-
response: RecordSchema,
|
|
17316
|
-
handler: handle(async (req, { db }) => {
|
|
17317
|
-
const body = await req.json();
|
|
17318
|
-
return moveRecord(db, need(req, "id"), body.target, body.where);
|
|
17319
|
-
})
|
|
17320
|
-
},
|
|
17321
|
-
{
|
|
17322
|
-
method: "DELETE",
|
|
17323
|
-
path: "/api/record",
|
|
17324
|
-
summary: "Delete a record. Query: ?id=<id>",
|
|
17325
|
-
response: OkSchema,
|
|
17326
|
-
handler: handle((req, { db }) => ({ ok: deleteRecord(db, need(req, "id")) }))
|
|
17327
|
-
},
|
|
17328
|
-
{
|
|
17329
|
-
method: "GET",
|
|
17330
|
-
path: "/api/documents",
|
|
17331
|
-
summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
|
|
17332
|
-
response: exports_external.array(DocumentSummarySchema),
|
|
17333
|
-
handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
|
|
17334
|
-
},
|
|
17335
|
-
{
|
|
17336
|
-
method: "POST",
|
|
17337
|
-
path: "/api/documents",
|
|
17338
|
-
summary: "Create a document",
|
|
17339
|
-
request: CreateDocumentReq,
|
|
17340
|
-
response: DocumentSchema,
|
|
17341
|
-
handler: handle(async (req, { db }) => {
|
|
17342
|
-
const body = await req.json();
|
|
17343
|
-
return createDocument(db, body);
|
|
17344
|
-
})
|
|
17345
|
-
},
|
|
17346
|
-
{
|
|
17347
|
-
method: "GET",
|
|
17348
|
-
path: "/api/document",
|
|
17349
|
-
summary: "Get one document with full body. Query: ?id=<id>",
|
|
17350
|
-
response: DocumentSchema,
|
|
17351
|
-
handler: handle((req, { db }) => {
|
|
17352
|
-
const doc2 = getDocument(db, need(req, "id"));
|
|
17353
|
-
return doc2 ?? new Response("not found", { status: 404 });
|
|
17354
|
-
})
|
|
17355
|
-
},
|
|
17356
|
-
{
|
|
17357
|
-
method: "PATCH",
|
|
17358
|
-
path: "/api/document",
|
|
17359
|
-
summary: "Update a document's title/body/parent_id (null = top level). Query: ?id=<id>",
|
|
17360
|
-
request: UpdateDocumentReq,
|
|
17361
|
-
response: DocumentSchema,
|
|
17362
|
-
handler: handle(async (req, { db }) => {
|
|
17363
|
-
const body = await req.json();
|
|
17364
|
-
return updateDocument(db, need(req, "id"), body);
|
|
16541
|
+
const { content: _content, ...summary } = file2;
|
|
16542
|
+
return summary;
|
|
17365
16543
|
})
|
|
17366
16544
|
},
|
|
17367
16545
|
{
|
|
17368
16546
|
method: "DELETE",
|
|
17369
|
-
path: "/api/
|
|
17370
|
-
summary: "Delete a
|
|
17371
|
-
response:
|
|
17372
|
-
handler: handle((req, { db }) => ({
|
|
17373
|
-
|
|
17374
|
-
|
|
17375
|
-
method: "GET",
|
|
17376
|
-
path: "/api/search",
|
|
17377
|
-
summary: "Full-text search across documents and records. Query: ?q=<text>&limit=<n>",
|
|
17378
|
-
response: exports_external.array(SearchHitSchema),
|
|
17379
|
-
handler: handle((req, { db }) => {
|
|
17380
|
-
const limit = opt(req, "limit");
|
|
17381
|
-
return search(db, need(req, "q"), { limit: limit ? Number(limit) : undefined });
|
|
17382
|
-
})
|
|
16547
|
+
path: "/api/site/file",
|
|
16548
|
+
summary: "Delete a file. Query: ?site=<id|name>&path=<path>",
|
|
16549
|
+
response: exports_external.object({ ok: exports_external.boolean() }),
|
|
16550
|
+
handler: handle((req, { db }) => ({
|
|
16551
|
+
ok: deleteFile(db, resolveSite(db, need(req, "site")).id, need(req, "path"))
|
|
16552
|
+
}))
|
|
17383
16553
|
}
|
|
17384
16554
|
];
|
|
17385
16555
|
|
|
17386
|
-
// src/core/sync/
|
|
17387
|
-
|
|
17388
|
-
var SiteSchema = exports_external.object({
|
|
17389
|
-
id: exports_external.string(),
|
|
17390
|
-
name: exports_external.string(),
|
|
17391
|
-
title: exports_external.string().nullable(),
|
|
17392
|
-
created_hlc: exports_external.string()
|
|
17393
|
-
});
|
|
17394
|
-
var SiteFileSchema = exports_external.object({
|
|
17395
|
-
id: exports_external.string(),
|
|
17396
|
-
site_id: exports_external.string(),
|
|
17397
|
-
path: exports_external.string(),
|
|
17398
|
-
content_type: exports_external.string(),
|
|
17399
|
-
encoding: exports_external.string()
|
|
17400
|
-
});
|
|
17401
|
-
function need2(req, key) {
|
|
17402
|
-
const v = new URL(req.url).searchParams.get(key);
|
|
17403
|
-
if (!v)
|
|
17404
|
-
throw new Error(`missing query param: ${key}`);
|
|
17405
|
-
return v;
|
|
17406
|
-
}
|
|
17407
|
-
function handle2(fn) {
|
|
17408
|
-
return async (req, ctx) => {
|
|
17409
|
-
try {
|
|
17410
|
-
return Response.json(await fn(req, ctx) ?? null);
|
|
17411
|
-
} catch (e) {
|
|
17412
|
-
return Response.json({ error: e.message }, { status: 400 });
|
|
17413
|
-
}
|
|
17414
|
-
};
|
|
17415
|
-
}
|
|
17416
|
-
var sitesRoutes = [
|
|
17417
|
-
{
|
|
17418
|
-
method: "GET",
|
|
17419
|
-
path: "/api/sites",
|
|
17420
|
-
summary: "List published sites",
|
|
17421
|
-
response: exports_external.array(SiteSchema),
|
|
17422
|
-
handler: handle2((_req, { db }) => listSites(db))
|
|
17423
|
-
},
|
|
17424
|
-
{
|
|
17425
|
-
method: "GET",
|
|
17426
|
-
path: "/api/site/files",
|
|
17427
|
-
summary: "List a site's files (manifest). Query: ?site=<id|name>",
|
|
17428
|
-
response: exports_external.array(SiteFileSchema),
|
|
17429
|
-
handler: handle2((req, { db }) => listFiles(db, resolveSite(db, need2(req, "site")).id))
|
|
17430
|
-
}
|
|
17431
|
-
];
|
|
16556
|
+
// src/core/sync/peers-routes.ts
|
|
16557
|
+
init_errors();
|
|
17432
16558
|
|
|
17433
16559
|
// src/core/sync/pairing.ts
|
|
17434
16560
|
init_ids();
|
|
@@ -17483,6 +16609,7 @@ async function syncAllPeers(db) {
|
|
|
17483
16609
|
}
|
|
17484
16610
|
|
|
17485
16611
|
// src/core/sync/pairing.ts
|
|
16612
|
+
init_errors();
|
|
17486
16613
|
var DEFAULT_PAIR_TTL_MS = parseDuration(process.env.METAHUB_PAIR_TTL, 10 * 60000);
|
|
17487
16614
|
function generatePairingCode(db, ttlMs = DEFAULT_PAIR_TTL_MS) {
|
|
17488
16615
|
const now = Date.now();
|
|
@@ -17511,7 +16638,7 @@ function revokeGrant(db, tokenOrPrefix) {
|
|
|
17511
16638
|
}
|
|
17512
16639
|
function handlePairRequest(db, node, body) {
|
|
17513
16640
|
if (!redeemPairingCode(db, body.code)) {
|
|
17514
|
-
throw new
|
|
16641
|
+
throw new MhError("auth", "invalid or expired pairing code");
|
|
17515
16642
|
}
|
|
17516
16643
|
if (body.self_url) {
|
|
17517
16644
|
addPeer(db, { url: body.self_url, token: body.grant, node_id: body.node_id });
|
|
@@ -17528,7 +16655,7 @@ async function performPairing(db, node, remoteUrl, code, selfUrl) {
|
|
|
17528
16655
|
body: JSON.stringify(reqBody)
|
|
17529
16656
|
});
|
|
17530
16657
|
if (!res.ok)
|
|
17531
|
-
throw new
|
|
16658
|
+
throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `pairing failed: ${res.status} ${await res.text()}`);
|
|
17532
16659
|
const data = await res.json();
|
|
17533
16660
|
addPeer(db, { url: remoteUrl, token: data.grant, node_id: data.node_id });
|
|
17534
16661
|
return { node_id: data.node_id, url: remoteUrl };
|
|
@@ -17572,14 +16699,14 @@ var GrantSchema = exports_external.object({
|
|
|
17572
16699
|
created_at: exports_external.number().nullable()
|
|
17573
16700
|
});
|
|
17574
16701
|
var RevokeRes = exports_external.object({ revoked: exports_external.number() });
|
|
17575
|
-
var
|
|
17576
|
-
function
|
|
16702
|
+
var OkSchema = exports_external.object({ ok: exports_external.boolean() });
|
|
16703
|
+
function need2(req, key) {
|
|
17577
16704
|
const v = new URL(req.url).searchParams.get(key);
|
|
17578
16705
|
if (!v)
|
|
17579
|
-
throw new
|
|
16706
|
+
throw new MhError("invalid_input", `missing query param: ${key}`);
|
|
17580
16707
|
return v;
|
|
17581
16708
|
}
|
|
17582
|
-
function
|
|
16709
|
+
function handle2(fn) {
|
|
17583
16710
|
return async (req, ctx) => {
|
|
17584
16711
|
try {
|
|
17585
16712
|
const out = await fn(req, ctx);
|
|
@@ -17587,7 +16714,7 @@ function handle3(fn) {
|
|
|
17587
16714
|
return out;
|
|
17588
16715
|
return Response.json(out ?? null);
|
|
17589
16716
|
} catch (e) {
|
|
17590
|
-
return
|
|
16717
|
+
return errorResponse(e);
|
|
17591
16718
|
}
|
|
17592
16719
|
};
|
|
17593
16720
|
}
|
|
@@ -17598,7 +16725,7 @@ var peersRoutes = [
|
|
|
17598
16725
|
summary: "Pairing handshake: redeem a one-time code and exchange credentials",
|
|
17599
16726
|
request: PairRequestSchema,
|
|
17600
16727
|
response: PairResponseSchema,
|
|
17601
|
-
handler:
|
|
16728
|
+
handler: handle2(async (req, { db, node }) => {
|
|
17602
16729
|
const body = await req.json();
|
|
17603
16730
|
return handlePairRequest(db, node, body);
|
|
17604
16731
|
})
|
|
@@ -17608,7 +16735,7 @@ var peersRoutes = [
|
|
|
17608
16735
|
path: "/api/pair/new",
|
|
17609
16736
|
summary: "Mint a one-time pairing code for another device to redeem",
|
|
17610
16737
|
response: PairingCodeSchema,
|
|
17611
|
-
handler:
|
|
16738
|
+
handler: handle2((_req, { db }) => generatePairingCode(db))
|
|
17612
16739
|
},
|
|
17613
16740
|
{
|
|
17614
16741
|
method: "POST",
|
|
@@ -17616,7 +16743,7 @@ var peersRoutes = [
|
|
|
17616
16743
|
summary: "Pair this server with a remote one using its one-time code",
|
|
17617
16744
|
request: AddPeerReq,
|
|
17618
16745
|
response: AddPeerRes,
|
|
17619
|
-
handler:
|
|
16746
|
+
handler: handle2(async (req, { db, node }) => {
|
|
17620
16747
|
const body = await req.json();
|
|
17621
16748
|
return performPairing(db, node, body.url, body.code, body.self_url);
|
|
17622
16749
|
})
|
|
@@ -17626,16 +16753,16 @@ var peersRoutes = [
|
|
|
17626
16753
|
path: "/api/peers",
|
|
17627
16754
|
summary: "List configured sync peers with status",
|
|
17628
16755
|
response: exports_external.array(PeerSchema),
|
|
17629
|
-
handler:
|
|
16756
|
+
handler: handle2((_req, { db }) => listPeers(db))
|
|
17630
16757
|
},
|
|
17631
16758
|
{
|
|
17632
16759
|
method: "PATCH",
|
|
17633
16760
|
path: "/api/peer",
|
|
17634
16761
|
summary: "Update a peer (enable/disable or label). Query: ?url=<url>",
|
|
17635
16762
|
request: UpdatePeerReq,
|
|
17636
|
-
response:
|
|
17637
|
-
handler:
|
|
17638
|
-
const url2 =
|
|
16763
|
+
response: OkSchema,
|
|
16764
|
+
handler: handle2(async (req, { db }) => {
|
|
16765
|
+
const url2 = need2(req, "url");
|
|
17639
16766
|
const body = await req.json();
|
|
17640
16767
|
if (body.enabled != null)
|
|
17641
16768
|
setPeerEnabled(db, url2, body.enabled);
|
|
@@ -17648,33 +16775,51 @@ var peersRoutes = [
|
|
|
17648
16775
|
method: "DELETE",
|
|
17649
16776
|
path: "/api/peer",
|
|
17650
16777
|
summary: "Remove a sync peer. Query: ?url=<url>",
|
|
17651
|
-
response:
|
|
17652
|
-
handler:
|
|
16778
|
+
response: OkSchema,
|
|
16779
|
+
handler: handle2((req, { db }) => ({ ok: removePeer(db, need2(req, "url")) }))
|
|
17653
16780
|
},
|
|
17654
16781
|
{
|
|
17655
16782
|
method: "POST",
|
|
17656
16783
|
path: "/api/peer/sync",
|
|
17657
16784
|
summary: "Manually sync once with a peer. Query: ?url=<url>",
|
|
17658
16785
|
response: PeerSyncRes,
|
|
17659
|
-
handler:
|
|
16786
|
+
handler: handle2((req, { db }) => syncPeer(db, need2(req, "url")))
|
|
17660
16787
|
},
|
|
17661
16788
|
{
|
|
17662
16789
|
method: "GET",
|
|
17663
16790
|
path: "/api/grants",
|
|
17664
16791
|
summary: "List credentials this server issued and accepts on /sync (inbound)",
|
|
17665
16792
|
response: exports_external.array(GrantSchema),
|
|
17666
|
-
handler:
|
|
16793
|
+
handler: handle2((_req, { db }) => listGrants(db))
|
|
17667
16794
|
},
|
|
17668
16795
|
{
|
|
17669
16796
|
method: "DELETE",
|
|
17670
16797
|
path: "/api/grant",
|
|
17671
16798
|
summary: "Revoke an issued credential by token or prefix. Query: ?token=<token>",
|
|
17672
16799
|
response: RevokeRes,
|
|
17673
|
-
handler:
|
|
16800
|
+
handler: handle2((req, { db }) => ({ revoked: revokeGrant(db, need2(req, "token")) }))
|
|
17674
16801
|
}
|
|
17675
16802
|
];
|
|
17676
16803
|
|
|
17677
16804
|
// src/core/sync/routes.ts
|
|
16805
|
+
init_errors();
|
|
16806
|
+
var HTTP_STATUS = {
|
|
16807
|
+
invalid_input: 400,
|
|
16808
|
+
not_found: 404,
|
|
16809
|
+
ambiguous: 400,
|
|
16810
|
+
stale: 409,
|
|
16811
|
+
conflict: 409,
|
|
16812
|
+
auth: 401,
|
|
16813
|
+
network: 502,
|
|
16814
|
+
port_in_use: 500
|
|
16815
|
+
};
|
|
16816
|
+
function errorResponse(e) {
|
|
16817
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
16818
|
+
const code = errorCode(e);
|
|
16819
|
+
return Response.json(code ? { error: message, code } : { error: message }, {
|
|
16820
|
+
status: code ? HTTP_STATUS[code] : 400
|
|
16821
|
+
});
|
|
16822
|
+
}
|
|
17678
16823
|
var syncRoutes = [
|
|
17679
16824
|
{
|
|
17680
16825
|
method: "POST",
|
|
@@ -17699,12 +16844,12 @@ var syncRoutes = [
|
|
|
17699
16844
|
}
|
|
17700
16845
|
}
|
|
17701
16846
|
];
|
|
17702
|
-
var routes = [...syncRoutes, ...
|
|
16847
|
+
var routes = [...syncRoutes, ...sitesRoutes, ...peersRoutes];
|
|
17703
16848
|
|
|
17704
16849
|
// src/core/sync/openapi.ts
|
|
17705
|
-
function buildOpenApi(version2) {
|
|
16850
|
+
function buildOpenApi(version2, routes2 = routes) {
|
|
17706
16851
|
const paths = {};
|
|
17707
|
-
for (const route of
|
|
16852
|
+
for (const route of routes2) {
|
|
17708
16853
|
const op = {
|
|
17709
16854
|
summary: route.summary,
|
|
17710
16855
|
responses: {
|
|
@@ -17906,11 +17051,11 @@ function unauthorized() {
|
|
|
17906
17051
|
}
|
|
17907
17052
|
|
|
17908
17053
|
// src/core/sync/server.ts
|
|
17909
|
-
class PortInUseError extends
|
|
17054
|
+
class PortInUseError extends MhError {
|
|
17910
17055
|
port;
|
|
17911
17056
|
host;
|
|
17912
17057
|
constructor(port, host) {
|
|
17913
|
-
super(`port ${port} is already in use \u2014 another mh server may be running. ` + `Stop it, or start on a free port with --port <n>.`);
|
|
17058
|
+
super("port_in_use", `port ${port} is already in use \u2014 another mh server may be running. ` + `Stop it, or start on a free port with --port <n>.`);
|
|
17914
17059
|
this.port = port;
|
|
17915
17060
|
this.host = host;
|
|
17916
17061
|
this.name = "PortInUseError";
|
|
@@ -17928,6 +17073,7 @@ function startServer(opts = {}) {
|
|
|
17928
17073
|
const db = openMetahub();
|
|
17929
17074
|
const node = getNodeId(db);
|
|
17930
17075
|
const ctx = { db, node };
|
|
17076
|
+
const allRoutes = opts.ui ? [...routes, ...opts.ui.routes] : routes;
|
|
17931
17077
|
const cfg = getServerConfig(db);
|
|
17932
17078
|
const port = opts.port ?? cfg.port;
|
|
17933
17079
|
const host = opts.host ?? cfg.host;
|
|
@@ -17961,14 +17107,13 @@ function startServer(opts = {}) {
|
|
|
17961
17107
|
return r ? Response.json(r) : unauthorized();
|
|
17962
17108
|
}
|
|
17963
17109
|
if (req.method === "GET" && url2.pathname === "/docs.json") {
|
|
17964
|
-
return Response.json(buildOpenApi(package_default.version));
|
|
17110
|
+
return Response.json(buildOpenApi(package_default.version, allRoutes));
|
|
17965
17111
|
}
|
|
17966
17112
|
if (req.method === "GET" && url2.pathname === "/docs") {
|
|
17967
17113
|
return withShim(new Response(scalarHtml("/docs.json"), { headers: HTML_HEADERS }), auth);
|
|
17968
17114
|
}
|
|
17969
|
-
if (req.method === "GET" &&
|
|
17970
|
-
const
|
|
17971
|
-
const res = await serveWebui2(req);
|
|
17115
|
+
if (req.method === "GET" && opts.ui) {
|
|
17116
|
+
const res = await opts.ui.serveAssets(req);
|
|
17972
17117
|
if (res)
|
|
17973
17118
|
return withShim(res, auth);
|
|
17974
17119
|
}
|
|
@@ -17978,7 +17123,7 @@ function startServer(opts = {}) {
|
|
|
17978
17123
|
if (res)
|
|
17979
17124
|
return withShim(res, auth);
|
|
17980
17125
|
}
|
|
17981
|
-
const route =
|
|
17126
|
+
const route = allRoutes.find((r) => r.method === req.method && r.path === url2.pathname);
|
|
17982
17127
|
if (route)
|
|
17983
17128
|
return route.handler(req, ctx);
|
|
17984
17129
|
return new Response("not found", { status: 404 });
|
|
@@ -17990,7 +17135,7 @@ function startServer(opts = {}) {
|
|
|
17990
17135
|
throw new PortInUseError(port, host);
|
|
17991
17136
|
}
|
|
17992
17137
|
if (code === "EACCES") {
|
|
17993
|
-
throw new
|
|
17138
|
+
throw new MhError("invalid_input", `permission denied binding ${host}:${port} \u2014 ports below 1024 need elevated privileges; pick a higher port with --port`);
|
|
17994
17139
|
}
|
|
17995
17140
|
throw e;
|
|
17996
17141
|
}
|
|
@@ -18020,7 +17165,1826 @@ function startServer(opts = {}) {
|
|
|
18020
17165
|
}
|
|
18021
17166
|
};
|
|
18022
17167
|
}
|
|
17168
|
+
// src/webui/server/assets.ts
|
|
17169
|
+
import { fileURLToPath } from "url";
|
|
17170
|
+
import { readdirSync, statSync } from "fs";
|
|
17171
|
+
import { join as join3 } from "path";
|
|
17172
|
+
|
|
17173
|
+
// src/webui/styles.css
|
|
17174
|
+
var styles_default = ` :root {
|
|
17175
|
+
/* Tells browser chrome (status-bar text, form controls, soft keyboard,
|
|
17176
|
+
overscroll canvas) which appearance the page renders. Keyed off
|
|
17177
|
+
data-resolved, so a manual theme toggle retunes it too. */
|
|
17178
|
+
color-scheme: light;
|
|
17179
|
+
--bg:#ffffff; --surface:#fbfbfa; --surface-2:#f1f1ef; --sidebar:#f7f7f5;
|
|
17180
|
+
--fg:#2c2c30; --fg-soft:#5b5b62; --muted:#75757f; --line:#ebebe8; --line-strong:#dededb;
|
|
17181
|
+
--accent:#4a55d6; --accent-fg:#ffffff; --accent-soft:#eef0ff; --danger:#d6473b; --danger-soft:#fdeceb;
|
|
17182
|
+
--success:#1f8f3e;
|
|
17183
|
+
--hover:rgba(45,45,55,.045); --hover-2:rgba(45,45,55,.08);
|
|
17184
|
+
--shadow-sm:0 1px 2px rgba(20,20,40,.06),0 1px 1px rgba(20,20,40,.04);
|
|
17185
|
+
--shadow-md:0 4px 12px rgba(20,20,40,.08),0 2px 4px rgba(20,20,40,.05);
|
|
17186
|
+
--shadow-lg:0 16px 48px rgba(20,20,40,.16),0 4px 12px rgba(20,20,40,.08);
|
|
17187
|
+
--radius:8px; --radius-sm:6px; --radius-lg:14px; --radius-xl:16px;
|
|
17188
|
+
--fs-body:14px; --fs-ui:13.5px; --fs-sm:12.5px; --fs-xs:11.5px; --fs-micro:11px;
|
|
17189
|
+
--ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
17190
|
+
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
|
|
17191
|
+
--note-lh:1.6;
|
|
17192
|
+
--code-bg:#f8f8f6;
|
|
17193
|
+
--hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
|
|
17194
|
+
--hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
|
|
17195
|
+
}
|
|
17196
|
+
/* Dark palette \u2014 exactly once. JS resolves the user's choice (including
|
|
17197
|
+
* "system" \u2192 the OS preference) to <html data-resolved="light|dark"> before
|
|
17198
|
+
* first paint (FOUC script in the shell) and on every change (theme.ts), so
|
|
17199
|
+
* CSS never needs a prefers-color-scheme duplicate of this block. */
|
|
17200
|
+
:root[data-resolved="dark"] {
|
|
17201
|
+
color-scheme: dark;
|
|
17202
|
+
--bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
|
|
17203
|
+
--fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#8b8b95; --line:#2c2c30; --line-strong:#3a3a40;
|
|
17204
|
+
--accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
|
|
17205
|
+
--success:#3fb950;
|
|
17206
|
+
--hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
|
|
17207
|
+
--shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
|
|
17208
|
+
--code-bg:#1d1d20;
|
|
17209
|
+
--hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
|
|
17210
|
+
--hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
|
|
17211
|
+
}
|
|
17212
|
+
* { box-sizing: border-box; }
|
|
17213
|
+
html, body { margin: 0; height: 100%; }
|
|
17214
|
+
/* Explicit backgrounds on BOTH html and body: iOS 26 Safari tints its liquid
|
|
17215
|
+
glass chrome by sampling page CSS (theme-color is ignored there), and a
|
|
17216
|
+
transparent root is a known path to white toolbars on dark pages. */
|
|
17217
|
+
html { background: var(--bg); scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; }
|
|
17218
|
+
/* Honor reduced motion everywhere; near-zero (not none) so animationend /
|
|
17219
|
+
final keyframe states still apply. The empty-state illustration adds its
|
|
17220
|
+
own targeted overrides below. */
|
|
17221
|
+
@media (prefers-reduced-motion: reduce) {
|
|
17222
|
+
*, ::before, ::after {
|
|
17223
|
+
animation-duration:.01ms !important; animation-iteration-count:1 !important;
|
|
17224
|
+
transition-duration:.01ms !important;
|
|
17225
|
+
}
|
|
17226
|
+
}
|
|
17227
|
+
body { font-family: var(--ui); font-size: var(--fs-body); line-height: 1.55; color: var(--fg); background: var(--bg);
|
|
17228
|
+
overflow: hidden; text-rendering: auto; }
|
|
17229
|
+
button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
|
|
17230
|
+
input, textarea { font: inherit; color: inherit; }
|
|
17231
|
+
/* Keyboard focus must be visible everywhere (mouse/touch :focus stays clean
|
|
17232
|
+
via :focus-visible). :where() keeps specificity at zero so any component
|
|
17233
|
+
can override; contentEditable surfaces keep their caret as the indicator. */
|
|
17234
|
+
:where(button, [role="button"], select, [tabindex]):focus-visible {
|
|
17235
|
+
outline: 2px solid var(--accent); outline-offset: 2px;
|
|
17236
|
+
}
|
|
17237
|
+
.muted { color: var(--muted); }
|
|
17238
|
+
svg { display: block; }
|
|
17239
|
+
.ico { width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:1.75; stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
17240
|
+
.ico.sm { width:14px; height:14px; }
|
|
17241
|
+
::-webkit-scrollbar { width:10px; height:10px; }
|
|
17242
|
+
::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:3px solid transparent; background-clip:padding-box; }
|
|
17243
|
+
::-webkit-scrollbar-thumb:hover { background:var(--muted); background-clip:padding-box; border:3px solid transparent; }
|
|
17244
|
+
/* Scroll containers swallow their own overscroll: no rubber-banding the whole
|
|
17245
|
+
page / accidental pull-to-refresh when an inner list hits its end. */
|
|
17246
|
+
.content, .sb-scroll, .peek-body, .modal-body, .pop, .qn-body { overscroll-behavior:contain; }
|
|
17247
|
+
.tablescroll, .tl-scroll, .board { overscroll-behavior-x:contain; }
|
|
17248
|
+
|
|
17249
|
+
/* dvh tracks the *visible* viewport on mobile (iOS Safari's collapsing
|
|
17250
|
+
address bar); the vh line before it is the fallback older engines keep. */
|
|
17251
|
+
#app { display: flex; height: 100vh; height: 100dvh; }
|
|
17252
|
+
|
|
17253
|
+
/* sidebar */
|
|
17254
|
+
.sidebar { width:268px; flex:none; background:var(--sidebar); border-right:1px solid var(--line);
|
|
17255
|
+
display:flex; flex-direction:column; position:relative; transition:margin-left .22s cubic-bezier(.4,0,.2,1); }
|
|
17256
|
+
.sb-resizer { position:absolute; top:0; right:-3px; width:6px; height:100%; cursor:col-resize; z-index:5; }
|
|
17257
|
+
.sb-head { display:flex; align-items:center; gap:8px; padding:14px 14px 10px; }
|
|
17258
|
+
.brand { display:flex; align-items:center; gap:8px; flex:1; font-weight:700; font-size:15px; letter-spacing:-.01em; }
|
|
17259
|
+
.brand .mark { width:24px; height:24px; border-radius:7px;
|
|
17260
|
+
background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
|
|
17261
|
+
display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
|
|
17262
|
+
.brand .mark svg { width:15px; height:15px; stroke:#fff; }
|
|
17263
|
+
.sb-search { margin:0 12px 8px; display:flex; align-items:center; gap:7px; cursor:text;
|
|
17264
|
+
background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
|
|
17265
|
+
box-shadow:var(--shadow-sm); transition:border-color .15s; }
|
|
17266
|
+
.sb-search:focus-within { border-color:var(--accent); }
|
|
17267
|
+
.sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
|
|
17268
|
+
.sb-search kbd { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
|
|
17269
|
+
.sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
|
|
17270
|
+
.sb-section { margin-top:12px; }
|
|
17271
|
+
.sb-section-head { display:flex; align-items:center; justify-content:space-between;
|
|
17272
|
+
font-size:var(--fs-micro); font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); padding:4px 8px; }
|
|
17273
|
+
.sb-section-head .add { opacity:0; padding:3px; border-radius:5px; color:var(--muted); transition:opacity .12s; }
|
|
17274
|
+
.sb-section:hover .add { opacity:1; }
|
|
17275
|
+
.sb-section-head .add:hover { background:var(--hover-2); color:var(--fg); }
|
|
17276
|
+
.navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; margin-bottom:2px; border-radius:var(--radius-sm);
|
|
17277
|
+
color:var(--fg-soft); font-size:var(--fs-ui); position:relative; white-space:nowrap; transition:background .1s; }
|
|
17278
|
+
.navitem:hover { background:var(--hover-2); }
|
|
17279
|
+
.navitem.active { background:var(--bg); color:var(--fg); font-weight:600; box-shadow:var(--shadow-sm); }
|
|
17280
|
+
.navitem .tw { width:18px; height:18px; display:grid; place-items:center; color:var(--muted); border-radius:4px; flex:none; }
|
|
17281
|
+
.navitem .tw:hover { background:var(--hover-2); }
|
|
17282
|
+
.navitem .tw svg { width:13px; height:13px; transition:transform .12s; }
|
|
17283
|
+
.navitem .tw.open svg { transform:rotate(90deg); }
|
|
17284
|
+
.navitem .emoji { width:20px; text-align:center; flex:none; font-size:var(--fs-body); }
|
|
17285
|
+
.navitem .label { flex:1; overflow:hidden; text-overflow:ellipsis; }
|
|
17286
|
+
.navitem .acts { display:flex; gap:1px; opacity:0; }
|
|
17287
|
+
.navitem:hover .acts { opacity:1; }
|
|
17288
|
+
.navitem .acts button { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
17289
|
+
.navitem .acts button:hover { background:var(--hover-2); color:var(--fg); }
|
|
17290
|
+
.navchildren { margin-left:16px; border-left:1px solid var(--line-strong); padding-left:2px; }
|
|
17291
|
+
.navitem.drop-into { box-shadow:inset 0 0 0 2px var(--accent); }
|
|
17292
|
+
.navitem.drop-before { box-shadow:inset 0 2px 0 0 var(--accent); }
|
|
17293
|
+
.navitem.drop-after { box-shadow:inset 0 -2px 0 0 var(--accent); }
|
|
17294
|
+
.dragging { opacity:.35; }
|
|
17295
|
+
/* Slim status row: sites/settings icon buttons left, version right. */
|
|
17296
|
+
.sb-footer { flex:none; display:flex; align-items:center; gap:2px; padding:5px 10px; border-top:1px solid var(--line); }
|
|
17297
|
+
.sb-footer .sb-act { display:grid; place-items:center; width:28px; height:28px; flex:none;
|
|
17298
|
+
border-radius:var(--radius-sm); color:var(--muted); position:relative; }
|
|
17299
|
+
.sb-footer .sb-act:hover { background:var(--hover-2); color:var(--fg); }
|
|
17300
|
+
.sb-footer .sb-act.active { background:var(--bg); color:var(--fg); box-shadow:var(--shadow-sm); }
|
|
17301
|
+
.sb-footer .sb-act .nav-dot { position:absolute; top:4px; right:4px; box-shadow:0 0 0 2px var(--sidebar); }
|
|
17302
|
+
.sbf-ver { margin-left:auto; font-family:var(--mono); font-size:var(--fs-micro); color:var(--muted);
|
|
17303
|
+
letter-spacing:-.01em; user-select:text; }
|
|
17304
|
+
/* Sites/settings as sb-head icon buttons \u2014 the mobile home's replacement for
|
|
17305
|
+
the .sb-footer row (swapped in the mobile media block below). */
|
|
17306
|
+
.sb-act { display:none; }
|
|
17307
|
+
.nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
|
|
17308
|
+
box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
|
|
17309
|
+
|
|
17310
|
+
/* settings page */
|
|
17311
|
+
.set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
|
|
17312
|
+
.set-title { font-size:26px; font-weight:700; letter-spacing:-.02em; color:var(--fg); }
|
|
17313
|
+
.set-sub { color:var(--muted); margin-top:4px; }
|
|
17314
|
+
.set-section { margin-top:34px; }
|
|
17315
|
+
.set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
|
|
17316
|
+
.set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
|
|
17317
|
+
.set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
|
|
17318
|
+
flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
|
|
17319
|
+
.set-footer-sep { opacity:.45; }
|
|
17320
|
+
.ver-num { font-family:var(--mono); font-size:var(--fs-micro); color:var(--fg-soft); letter-spacing:-.01em; }
|
|
17321
|
+
.ver-up { display:inline-flex; align-items:center; gap:6px; }
|
|
17322
|
+
.ver-up.err .ver-msg { color:var(--danger); }
|
|
17323
|
+
.ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
|
|
17324
|
+
color:var(--muted); cursor:pointer; transition:color .12s; }
|
|
17325
|
+
.ver-act:hover { color:var(--accent); }
|
|
17326
|
+
.ver-act.accent { color:var(--accent); font-weight:600; }
|
|
17327
|
+
.ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
|
|
17328
|
+
.ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
|
|
17329
|
+
.ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
|
|
17330
|
+
@keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
|
17331
|
+
.theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
|
|
17332
|
+
.theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
|
|
17333
|
+
text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
|
|
17334
|
+
background:var(--surface); color:var(--fg-soft); box-shadow:var(--shadow-sm);
|
|
17335
|
+
transition:border-color .14s, background .14s, transform .14s, box-shadow .14s; }
|
|
17336
|
+
.theme-card:hover { border-color:var(--muted); transform:translateY(-1px); }
|
|
17337
|
+
.theme-card .tc-ico { width:34px; height:34px; display:grid; place-items:center; border-radius:9px;
|
|
17338
|
+
background:var(--surface-2); color:var(--fg); }
|
|
17339
|
+
.theme-card .tc-ico svg { width:18px; height:18px; }
|
|
17340
|
+
.theme-card .tc-name { font-weight:600; font-size:var(--fs-body); color:var(--fg); }
|
|
17341
|
+
.theme-card .tc-desc { font-size:12px; color:var(--muted); line-height:1.45; }
|
|
17342
|
+
.theme-card .tc-check { position:absolute; top:10px; right:10px; width:18px; height:18px; border-radius:50%;
|
|
17343
|
+
background:var(--accent); color:var(--accent-fg); display:grid; place-items:center; opacity:0; transform:scale(.6); transition:opacity .14s, transform .14s; }
|
|
17344
|
+
.theme-card .tc-check svg { width:12px; height:12px; stroke-width:2.6; }
|
|
17345
|
+
.theme-card.sel { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset, var(--shadow-sm); }
|
|
17346
|
+
.theme-card.sel .tc-ico { background:var(--accent); color:var(--accent-fg); }
|
|
17347
|
+
.theme-card.sel .tc-check { opacity:1; transform:scale(1); }
|
|
17348
|
+
@media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
|
|
17349
|
+
|
|
17350
|
+
/* sync devices */
|
|
17351
|
+
.peer-actions { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
|
|
17352
|
+
.peer-actions .btn { display:inline-flex; align-items:center; gap:6px; }
|
|
17353
|
+
.peer-list { display:flex; flex-direction:column; gap:8px; }
|
|
17354
|
+
.peer-row { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--line);
|
|
17355
|
+
border-radius:var(--radius); background:var(--surface); }
|
|
17356
|
+
.peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
|
|
17357
|
+
.peer-dot.on { background:var(--success); } .peer-dot.err { background:var(--danger); } .peer-dot.off { background:var(--muted); }
|
|
17358
|
+
.peer-main { flex:1; min-width:0; }
|
|
17359
|
+
.peer-url { font-weight:600; font-size:var(--fs-body); color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17360
|
+
.peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17361
|
+
.peer-menu { flex:none; padding:6px; }
|
|
17362
|
+
.pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
|
|
17363
|
+
text-align:center; padding:16px; border:1px dashed var(--line); border-radius:var(--radius); background:var(--surface); color:var(--fg); }
|
|
17364
|
+
|
|
17365
|
+
/* main */
|
|
17366
|
+
/* Opaque background matters: on mobile this is a full-screen fixed pane that
|
|
17367
|
+
iOS 26 Safari samples for its toolbar tint \u2014 transparent would read white. */
|
|
17368
|
+
.main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; background:var(--bg); }
|
|
17369
|
+
.topbar { display:flex; align-items:center; gap:6px; padding:10px 18px; border-bottom:1px solid var(--line); min-height:49px; }
|
|
17370
|
+
.topbar.bare { border-bottom:0; }
|
|
17371
|
+
.topbar .hamburger { display:none; }
|
|
17372
|
+
.topbar .hamburger.show-collapsed { display:grid; }
|
|
17373
|
+
.sidebar.collapsed { overflow:hidden; }
|
|
17374
|
+
.sidebar.collapsed .sb-resizer { pointer-events:none; }
|
|
17375
|
+
.crumb { display:flex; align-items:center; gap:7px; flex:1; min-width:0; font-size:var(--fs-ui); color:var(--fg); font-weight:500; }
|
|
17376
|
+
.crumb .emoji { font-size:15px; } .crumb .sub { color:var(--muted); font-weight:400; }
|
|
17377
|
+
.iconbtn { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-sm); color:var(--fg-soft); transition:background .12s; }
|
|
17378
|
+
.iconbtn:hover { background:var(--hover-2); color:var(--fg); }
|
|
17379
|
+
.content { flex:1; overflow:auto; }
|
|
17380
|
+
.btn { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:var(--radius-sm); font-size:13px; font-weight:500; transition:all .12s; white-space:nowrap; }
|
|
17381
|
+
.btn-ghost { color:var(--fg-soft); } .btn-ghost:hover { background:var(--hover-2); color:var(--fg); }
|
|
17382
|
+
.btn-secondary { background:var(--bg); border:1px solid var(--line-strong); color:var(--fg); box-shadow:var(--shadow-sm); }
|
|
17383
|
+
.btn-secondary:hover { background:var(--surface-2); }
|
|
17384
|
+
.btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
|
|
17385
|
+
.btn-primary:hover { filter:brightness(1.07); }
|
|
17386
|
+
.btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
|
|
17387
|
+
|
|
17388
|
+
/* desktop app (frameless): drop the sidebar logo; on macOS reserve a draggable
|
|
17389
|
+
strip for the inset traffic lights so they never overlap our controls */
|
|
17390
|
+
body.desktop .brand { display:none; }
|
|
17391
|
+
/* logo gone \u2192 keep the collapse button on the right (where .brand's flex used to push it) */
|
|
17392
|
+
body.desktop .sb-head .iconbtn { margin-left:auto; }
|
|
17393
|
+
body.desktop-mac .sb-head { -webkit-app-region:drag; }
|
|
17394
|
+
body.desktop-mac .sb-head .iconbtn { -webkit-app-region:no-drag; }
|
|
17395
|
+
body.desktop-mac .topbar { -webkit-app-region:drag; }
|
|
17396
|
+
body.desktop-mac .topbar .iconbtn, body.desktop-mac .topbar .btn { -webkit-app-region:no-drag; }
|
|
17397
|
+
/* peek drawer header overlaps the topbar drag region \u2192 clear its buttons so they stay clickable */
|
|
17398
|
+
body.desktop-mac .peek-head { -webkit-app-region:drag; }
|
|
17399
|
+
body.desktop-mac .peek-head .iconbtn, body.desktop-mac .peek-head .btn { -webkit-app-region:no-drag; }
|
|
17400
|
+
/* sidebar collapsed \u2192 traffic lights sit over the main header; clear the hamburger */
|
|
17401
|
+
body.desktop-mac .sidebar.collapsed ~ .main .topbar { padding-left:80px; }
|
|
17402
|
+
|
|
17403
|
+
/* document editor */
|
|
17404
|
+
.doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
|
|
17405
|
+
.doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
|
|
17406
|
+
.doc-title:empty::before { content:"\u65E0\u6807\u9898"; color:var(--muted); }
|
|
17407
|
+
.doc-meta { display:flex; gap:14px; color:var(--muted); font-size:var(--fs-sm); margin-bottom:22px; }
|
|
17408
|
+
.doc-meta span { display:inline-flex; align-items:center; gap:5px; }
|
|
17409
|
+
/* Save-conflict banner: the doc changed elsewhere; auto-save is paused until
|
|
17410
|
+
the user picks a side, so it must read as blocking, not as a toast. */
|
|
17411
|
+
.doc-conflict { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 14px; margin-bottom:16px; border:1px solid var(--danger); border-radius:var(--radius-sm); background:var(--danger-soft); font-size:13px; }
|
|
17412
|
+
.doc-conflict-msg { color:var(--danger); font-weight:600; flex:1; min-width:180px; }
|
|
17413
|
+
.doc.source-mode { min-height:100%; }
|
|
17414
|
+
.doc-source { display:block; width:100%; min-height:calc(100vh - 210px); min-height:calc(100dvh - 210px); resize:none; border:0;
|
|
17415
|
+
background:transparent; color:var(--fg); outline:none; overflow:hidden; padding:3px 2px 36vh;
|
|
17416
|
+
font-family:var(--ui); font-size:15px; line-height:1.7; tab-size:2; }
|
|
17417
|
+
/* floating table of contents: a column of indented tick marks in the right
|
|
17418
|
+
gutter that expands to a labelled list on hover (Notion / Vercel-docs style).
|
|
17419
|
+
Fixed to the viewport so it never disturbs the centred .doc column. */
|
|
17420
|
+
.doc-toc { position:fixed; top:72px; right:18px; z-index:40; display:flex; flex-direction:column; gap:2px;
|
|
17421
|
+
width:24px; max-height:calc(100vh - 120px); max-height:calc(100dvh - 120px); padding:6px; box-sizing:content-box; overflow:hidden;
|
|
17422
|
+
border:1px solid transparent; border-radius:var(--radius); background:transparent;
|
|
17423
|
+
transition:width .22s ease, background .22s ease, border-color .22s ease, box-shadow .22s ease; }
|
|
17424
|
+
.doc-toc:hover { width:228px; overflow-y:auto; background:var(--surface); border-color:var(--line);
|
|
17425
|
+
box-shadow:var(--shadow-md); }
|
|
17426
|
+
.toc-row { display:flex; align-items:center; gap:8px; width:100%; height:24px; padding:0 2px; border-radius:var(--radius-sm);
|
|
17427
|
+
color:var(--muted); text-align:left; cursor:pointer; transition:color .12s, background .12s; }
|
|
17428
|
+
.doc-toc:hover .toc-row { padding:3px 8px; height:auto; min-height:26px; }
|
|
17429
|
+
.doc-toc:hover .toc-row:hover { background:var(--hover); color:var(--fg); }
|
|
17430
|
+
.toc-row.active { color:var(--accent); }
|
|
17431
|
+
/* collapsed: right-aligned ticks, length by heading level; active is accented */
|
|
17432
|
+
.toc-tick { flex:none; margin-left:auto; height:2px; border-radius:2px; background:var(--line-strong);
|
|
17433
|
+
transition:background .12s, width .22s ease, margin .22s ease; }
|
|
17434
|
+
.toc-row.lvl-1 .toc-tick { width:16px; }
|
|
17435
|
+
.toc-row.lvl-2 .toc-tick { width:11px; }
|
|
17436
|
+
.toc-row.lvl-3 .toc-tick { width:7px; }
|
|
17437
|
+
.toc-row.active .toc-tick { background:var(--accent); }
|
|
17438
|
+
.doc-toc:hover .toc-tick { width:0; margin:0; }
|
|
17439
|
+
/* expanded: labels, indented by level; hidden until hover */
|
|
17440
|
+
.toc-label { flex:1; min-width:0; font-size:13px; line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
17441
|
+
opacity:0; transition:opacity .18s ease; }
|
|
17442
|
+
.doc-toc:hover .toc-label { opacity:1; }
|
|
17443
|
+
.toc-row.lvl-2 .toc-label { padding-left:12px; font-size:var(--fs-sm); }
|
|
17444
|
+
.toc-row.lvl-3 .toc-label { padding-left:24px; font-size:var(--fs-sm); color:var(--fg-soft); }
|
|
17445
|
+
.toc-row.active .toc-label { color:var(--accent); font-weight:550; }
|
|
17446
|
+
/* not enough room for the gutter \u2192 hide entirely (also covers mobile) */
|
|
17447
|
+
@media (max-width:1100px) { .doc-toc { display:none; } }
|
|
17448
|
+
|
|
17449
|
+
.block-wrap { position:relative; }
|
|
17450
|
+
.block-wrap.nested { padding-left:24px; }
|
|
17451
|
+
/* Subtle indent guide only for true sub-lists; suppressed for code/other
|
|
17452
|
+
nested content so code-in-list items don't stack a thicket of lines. */
|
|
17453
|
+
.block-wrap.nested:has(> .block.b-bullet, > .block.b-numbered, > .block.b-todo)::before {
|
|
17454
|
+
content:""; position:absolute; left:8px; top:2px; bottom:2px; width:1px; background:var(--line); }
|
|
17455
|
+
.block { position:relative; display:flex; align-items:flex-start; padding:1px 0; border-radius:4px; scroll-margin-top:60px; }
|
|
17456
|
+
.block .gutter { position:absolute; left:-52px; top:2px; display:flex; gap:1px; opacity:0; transition:opacity .12s; }
|
|
17457
|
+
.block:hover .gutter, .block.drop-before .gutter, .block.drop-after .gutter, .block.dragging .gutter { opacity:1; }
|
|
17458
|
+
.gutter button { width:22px; height:24px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
17459
|
+
.gutter button:hover { background:var(--hover-2); color:var(--fg); }
|
|
17460
|
+
.gutter .grip { cursor:grab; } .gutter .grip:active { cursor:grabbing; }
|
|
17461
|
+
.block.dragging { opacity:.42; background:var(--hover); }
|
|
17462
|
+
.block.drop-before::before, .block.drop-after::after { content:""; position:absolute; left:0; right:0; height:2px; background:var(--accent);
|
|
17463
|
+
border-radius:2px; pointer-events:none; box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 18%,transparent); }
|
|
17464
|
+
.block.drop-before::before { top:-1px; }
|
|
17465
|
+
.block.drop-after::after { bottom:-1px; }
|
|
17466
|
+
/* multi-block selection: whole-block tint + suppress stray native text highlight while dragging */
|
|
17467
|
+
.block.selected { background:var(--accent-soft); }
|
|
17468
|
+
.block.selected .editable::selection, .block.selected .editable *::selection { background:transparent; }
|
|
17469
|
+
.doc.selecting, .doc.selecting * { user-select:none; -webkit-user-select:none; cursor:default; }
|
|
17470
|
+
.block.list-code-host { height:0; min-height:0; padding:0; overflow:visible; }
|
|
17471
|
+
.block-wrap:hover > .block.list-code-host .gutter { opacity:1; }
|
|
17472
|
+
.block.list-code-host .marker { position:absolute; left:0; top:0; padding-top:4px; z-index:1; }
|
|
17473
|
+
/* The list item's host gutter controls the whole unit, so suppress the nested
|
|
17474
|
+
code block's own gutter (it would overlap the list marker on the same row). */
|
|
17475
|
+
.block.list-code-host + .block-wrap.nested .gutter { display:none; }
|
|
17476
|
+
.editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
|
|
17477
|
+
.editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
|
|
17478
|
+
/* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
|
|
17479
|
+
.editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
|
|
17480
|
+
.b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
|
|
17481
|
+
.b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
|
|
17482
|
+
.b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
|
|
17483
|
+
.b-h1 .gutter { top:22px; } .b-h2 .gutter { top:14px; } .b-h3 .gutter { top:7px; }
|
|
17484
|
+
.b-quote .editable { border-left:3px solid var(--line-strong); padding:1px 0 1px 16px; color:var(--fg-soft); font-style:italic; }
|
|
17485
|
+
|
|
17486
|
+
/* code block: transparent textarea over a highlight.js mirror */
|
|
17487
|
+
.b-code .codeblock { flex:1; min-width:0; }
|
|
17488
|
+
.codeblock { position:relative; --code-fs:12.5px; --code-lh:1.55; --code-pad:8px; margin:2px 0;
|
|
17489
|
+
background:var(--code-bg); border:1px solid var(--line); border-radius:8px; overflow:hidden; box-shadow:var(--shadow-sm); }
|
|
17490
|
+
/* lang + copy float bottom-right, revealed on hover */
|
|
17491
|
+
.code-tools { position:absolute; right:6px; bottom:6px; z-index:2; display:flex; align-items:center; gap:1px;
|
|
17492
|
+
padding:2px; border-radius:7px; border:1px solid var(--line); box-shadow:var(--shadow-sm);
|
|
17493
|
+
background:color-mix(in srgb,var(--code-bg) 82%,transparent); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
|
|
17494
|
+
opacity:0; transform:translateY(3px); pointer-events:none; transition:opacity .12s,transform .12s; }
|
|
17495
|
+
.codeblock:hover .code-tools, .code-tools:focus-within { opacity:1; transform:none; pointer-events:auto; }
|
|
17496
|
+
.code-lang { position:relative; display:inline-flex; align-items:center; }
|
|
17497
|
+
.code-lang select { appearance:none; -webkit-appearance:none; border:0; outline:none; background:none; cursor:pointer;
|
|
17498
|
+
font-family:var(--mono); font-size:10.5px; font-weight:500; color:var(--fg-soft); padding:3px 17px 3px 7px; border-radius:5px; }
|
|
17499
|
+
.code-lang select:hover { background:var(--hover-2); }
|
|
17500
|
+
.code-lang svg { position:absolute; right:3px; width:12px; height:12px; pointer-events:none; color:var(--muted); }
|
|
17501
|
+
.code-copy { display:inline-flex; align-items:center; gap:4px; font-size:10.5px; font-weight:500; color:var(--muted);
|
|
17502
|
+
padding:3px 7px; border-radius:5px; transition:background .12s,color .12s; }
|
|
17503
|
+
.code-copy:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
17504
|
+
.code-copy.ok { color:var(--accent); }
|
|
17505
|
+
.code-body { display:flex; align-items:stretch; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh); }
|
|
17506
|
+
.code-gutter { flex:none; text-align:right; white-space:pre; user-select:none; color:var(--muted); opacity:.55;
|
|
17507
|
+
padding:var(--code-pad) 8px var(--code-pad) 12px; border-right:1px solid var(--line); font-variant-numeric:tabular-nums; }
|
|
17508
|
+
.code-scroll { position:relative; flex:1; min-width:0; }
|
|
17509
|
+
.code-hl, .code-input { margin:0; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh);
|
|
17510
|
+
padding:var(--code-pad) 16px; tab-size:2; -moz-tab-size:2; white-space:pre; letter-spacing:0; }
|
|
17511
|
+
.code-hl { position:absolute; inset:0; z-index:0; overflow:hidden; pointer-events:none; background:none; color:var(--fg); }
|
|
17512
|
+
.code-hl code { display:block; font:inherit; background:none; padding:0; }
|
|
17513
|
+
.code-input { display:block; position:relative; z-index:1; width:100%; border:0; outline:none; resize:none;
|
|
17514
|
+
overflow-x:auto; overflow-y:hidden; background:transparent; color:transparent; caret-color:var(--fg);
|
|
17515
|
+
min-height:calc(var(--code-fs) * var(--code-lh) + var(--code-pad) * 2); }
|
|
17516
|
+
.code-input::placeholder { color:var(--muted); }
|
|
17517
|
+
|
|
17518
|
+
/* highlight.js token theme (cohesive with the app palette) */
|
|
17519
|
+
.hljs-comment, .hljs-quote { color:var(--hl-cmt); font-style:italic; }
|
|
17520
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-doctag { color:var(--hl-key); }
|
|
17521
|
+
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-meta .hljs-string { color:var(--hl-str); }
|
|
17522
|
+
.hljs-number, .hljs-bullet { color:var(--hl-num); }
|
|
17523
|
+
.hljs-title, .hljs-title.function_, .hljs-name, .hljs-selector-id, .hljs-selector-class { color:var(--hl-fn); }
|
|
17524
|
+
.hljs-type, .hljs-class .hljs-title, .hljs-built_in, .hljs-attr, .hljs-attribute, .hljs-property { color:var(--hl-type); }
|
|
17525
|
+
.hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-tag, .hljs-meta, .hljs-link { color:var(--hl-var); }
|
|
17526
|
+
.hljs-deletion { color:var(--danger); }
|
|
17527
|
+
.hljs-params, .hljs-subst { color:var(--fg); }
|
|
17528
|
+
.hljs-emphasis { font-style:italic; }
|
|
17529
|
+
.hljs-strong { font-weight:700; }
|
|
17530
|
+
.marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
|
|
17531
|
+
.b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
|
|
17532
|
+
display:flex; align-items:center; justify-content:center; }
|
|
17533
|
+
.b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
|
|
17534
|
+
.b-divider { padding:10px 0; }
|
|
17535
|
+
.b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
|
|
17536
|
+
.b-done .editable { color:var(--muted); text-decoration:line-through; }
|
|
17537
|
+
.editable b, .editable strong { font-weight:700; }
|
|
17538
|
+
.editable code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
|
|
17539
|
+
.editable a { color:var(--accent); }
|
|
17540
|
+
|
|
17541
|
+
/* document table (GFM pipe table block) */
|
|
17542
|
+
.b-table .doc-table-wrap { flex:1; min-width:0; margin:4px 0; }
|
|
17543
|
+
.doc-table-scroll { overflow-x:auto; padding:2px 0; }
|
|
17544
|
+
.doc-table-inner { width:max-content; }
|
|
17545
|
+
.doc-table-row { display:flex; align-items:stretch; }
|
|
17546
|
+
table.doc-table { border-collapse:separate; border-spacing:0; table-layout:fixed; width:max-content;
|
|
17547
|
+
border:1px solid var(--line); border-radius:var(--radius-sm); overflow:hidden; box-shadow:var(--shadow-sm); }
|
|
17548
|
+
table.doc-table td { position:relative; vertical-align:top; padding:0;
|
|
17549
|
+
border-right:1px solid var(--line); border-bottom:1px solid var(--line); }
|
|
17550
|
+
table.doc-table tr td:last-child { border-right:0; }
|
|
17551
|
+
table.doc-table tr:last-child td { border-bottom:0; }
|
|
17552
|
+
table.doc-table .doc-th { background:var(--surface); }
|
|
17553
|
+
.doc-td { outline:none; padding:7px 22px 7px 10px; min-height:20px; line-height:1.55; word-break:break-word; white-space:pre-wrap; }
|
|
17554
|
+
.doc-th .doc-td { font-weight:650; color:var(--fg); }
|
|
17555
|
+
.doc-td:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
|
|
17556
|
+
.doc-td b, .doc-td strong { font-weight:700; }
|
|
17557
|
+
.doc-td code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
|
|
17558
|
+
.doc-td a { color:var(--accent); }
|
|
17559
|
+
/* header cell: column menu + resize handle */
|
|
17560
|
+
.doc-col-menu { position:absolute; top:4px; right:4px; width:18px; height:18px; display:grid; place-items:center;
|
|
17561
|
+
color:var(--muted); border-radius:4px; opacity:0; transition:opacity .12s,background .12s; }
|
|
17562
|
+
.doc-th:hover .doc-col-menu { opacity:1; }
|
|
17563
|
+
.doc-col-menu:hover { background:var(--hover-2); color:var(--fg); }
|
|
17564
|
+
.doc-col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:3; touch-action:none; }
|
|
17565
|
+
.doc-col-resizer:hover, .doc-col-resizer.dragging { background:var(--accent); opacity:.35; }
|
|
17566
|
+
/* per-row delete: small handle floating at the first cell on row hover */
|
|
17567
|
+
.doc-row-del { position:absolute; top:3px; left:2px; width:18px; height:18px; display:grid; place-items:center;
|
|
17568
|
+
color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:5px; box-shadow:var(--shadow-sm);
|
|
17569
|
+
opacity:0; transition:opacity .12s; }
|
|
17570
|
+
table.doc-table tr:hover .doc-row-del { opacity:1; }
|
|
17571
|
+
.doc-row-del:hover { background:var(--danger-soft); color:var(--danger); border-color:var(--danger-soft); }
|
|
17572
|
+
/* add-column (right edge) and add-row (bottom edge) buttons */
|
|
17573
|
+
.doc-table-addcol { width:22px; margin-left:4px; flex:none; display:grid; place-items:center; color:var(--muted);
|
|
17574
|
+
border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
|
|
17575
|
+
.doc-table-addrow { width:100%; height:22px; margin-top:4px; display:grid; place-items:center; color:var(--muted);
|
|
17576
|
+
border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
|
|
17577
|
+
.doc-table-addcol:hover, .doc-table-addrow:hover { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
|
|
17578
|
+
|
|
17579
|
+
/* database / table */
|
|
17580
|
+
.db { padding:22px 36px 90px; min-width:0; }
|
|
17581
|
+
.db-head { display:flex; align-items:center; gap:12px; }
|
|
17582
|
+
.db-icon { width:38px; height:38px; border-radius:9px; background:var(--surface-2); display:grid; place-items:center; font-size:20px; }
|
|
17583
|
+
.db-title { font-size:27px; font-weight:700; letter-spacing:-.02em; outline:none; }
|
|
17584
|
+
.db-desc { color:var(--muted); font-size:13px; margin-top:2px; }
|
|
17585
|
+
.views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; }
|
|
17586
|
+
.view-tab { display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:var(--fs-ui); font-weight:500; color:var(--muted);
|
|
17587
|
+
border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s; }
|
|
17588
|
+
.view-tab.active { color:var(--fg); border-bottom-color:var(--accent); }
|
|
17589
|
+
.view-tab:hover { color:var(--fg); } .view-tab svg { width:14px; height:14px; }
|
|
17590
|
+
.toolbar { display:flex; align-items:center; gap:2px; padding:10px 0; }
|
|
17591
|
+
.toolbar .spacer { flex:1; }
|
|
17592
|
+
.tbtn { display:flex; align-items:center; gap:6px; padding:5px 9px; border-radius:var(--radius-sm); color:var(--fg-soft); font-size:13px; font-weight:500; }
|
|
17593
|
+
.tbtn:hover { background:var(--hover-2); color:var(--fg); }
|
|
17594
|
+
.tbtn.on { color:var(--accent); }
|
|
17595
|
+
.tablewrap { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-sm); }
|
|
17596
|
+
.tablescroll { overflow-x:auto; }
|
|
17597
|
+
/* fixed layout + max-content: each <col> width is authoritative, so the table
|
|
17598
|
+
is exactly the sum of its columns and resizing one column never redistributes
|
|
17599
|
+
width to its neighbours. min-width:100% fills the container when columns are
|
|
17600
|
+
narrow, with the slack absorbed only by the trailing auto (filler) column. */
|
|
17601
|
+
table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
|
|
17602
|
+
table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
|
|
17603
|
+
table.grid th:last-child, table.grid td:last-child { border-right:0; }
|
|
17604
|
+
table.grid tbody tr:last-child td { border-bottom:0; }
|
|
17605
|
+
thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
|
|
17606
|
+
.colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
|
|
17607
|
+
.colhead:hover { background:var(--hover); }
|
|
17608
|
+
.colhead .ti { color:var(--muted); } .colhead .ti svg { width:14px; height:14px; }
|
|
17609
|
+
.colhead .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17610
|
+
.col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
|
|
17611
|
+
.col-resizer:hover, .col-resizer.dragging { background:var(--accent); opacity:.35; }
|
|
17612
|
+
.addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
|
|
17613
|
+
/* trailing filler column: empty canvas to the right of the data, not a real cell */
|
|
17614
|
+
table.grid td.filler { border-right:0; border-bottom:0; background:transparent; }
|
|
17615
|
+
body.col-resizing { user-select:none; cursor:col-resize; }
|
|
17616
|
+
table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
|
|
17617
|
+
table.grid .gripcol, table.grid .rowgrip { width:26px; min-width:26px; max-width:26px; text-align:center; }
|
|
17618
|
+
td.cell-td { vertical-align:top; }
|
|
17619
|
+
.cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
|
|
17620
|
+
/* hover/active tint goes on the <td> (always full row height) not the inner
|
|
17621
|
+
.cell (only as tall as its content), so a taller sibling row never leaves a
|
|
17622
|
+
gap of un-tinted space at the bottom of the hovered cell. */
|
|
17623
|
+
td.cell-td { transition:background .1s; }
|
|
17624
|
+
td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
|
|
17625
|
+
tbody tr { transition:background .08s; }
|
|
17626
|
+
tbody tr:hover { background:var(--hover); }
|
|
17627
|
+
tbody tr.sel { background:var(--accent-soft); }
|
|
17628
|
+
/* cell range selection: per-cell fill; the accent rectangle outline is drawn via inline box-shadow on edge cells. Placed after :hover so it wins on equal specificity. */
|
|
17629
|
+
td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
|
|
17630
|
+
body.cell-selecting { user-select:none; cursor:cell; }
|
|
17631
|
+
body.cell-selecting .cell { cursor:cell; }
|
|
17632
|
+
table.grid tr.drop-before td { box-shadow:inset 0 2px 0 0 var(--accent); }
|
|
17633
|
+
table.grid tr.drop-after td { box-shadow:inset 0 -2px 0 0 var(--accent); }
|
|
17634
|
+
table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
|
|
17635
|
+
table.grid th.drop-after { box-shadow:inset -2px 0 0 0 var(--accent); }
|
|
17636
|
+
.rowgrip { color:transparent; cursor:grab; vertical-align:middle; touch-action:none; }
|
|
17637
|
+
.rowgrip[aria-disabled="true"] { cursor:not-allowed; }
|
|
17638
|
+
tbody tr:hover .rowgrip { color:var(--muted); }
|
|
17639
|
+
.colhead { touch-action:none; }
|
|
17640
|
+
.drag-source { opacity:.35; }
|
|
17641
|
+
body.table-dragging { user-select:none; cursor:grabbing; }
|
|
17642
|
+
.drag-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; display:flex; align-items:center;
|
|
17643
|
+
padding:7px 11px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; border:1px solid var(--line-strong);
|
|
17644
|
+
border-radius:7px; background:var(--bg); color:var(--fg); box-shadow:var(--shadow-lg); opacity:.94; font-size:13px; }
|
|
17645
|
+
.row-ghost { min-height:34px; }
|
|
17646
|
+
.col-ghost { justify-content:center; font-weight:600; color:var(--fg-soft); background:var(--surface); }
|
|
17647
|
+
.selcell { vertical-align:middle; }
|
|
17648
|
+
.selcell input { width:15px; height:15px; accent-color:var(--accent); opacity:0; cursor:pointer; vertical-align:middle; }
|
|
17649
|
+
tbody tr:hover .selcell input, tbody tr.sel .selcell input, .selcell input:checked, thead .selcell input { opacity:1; }
|
|
17650
|
+
.firstcell { position:relative; width:100%; min-width:0; }
|
|
17651
|
+
/* row actions float over the cell on hover, never reserving column width */
|
|
17652
|
+
/* right:-5px pulls the button out over the cell's 11px right padding so it
|
|
17653
|
+
sits ~6px from the column border instead of ~15px inside it */
|
|
17654
|
+
.rowactions { position:absolute; top:0; bottom:0; right:-5px; display:flex; align-items:flex-start;
|
|
17655
|
+
padding:1px 0 0; opacity:0; pointer-events:none; }
|
|
17656
|
+
tbody tr:hover .rowactions { opacity:1; pointer-events:auto; }
|
|
17657
|
+
/* square icon button \u2014 "open as page" (Notion-style) */
|
|
17658
|
+
.rowopen { width:23px; height:23px; flex:none; display:inline-flex; align-items:center; justify-content:center;
|
|
17659
|
+
border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--fg-soft); box-shadow:var(--shadow-sm); }
|
|
17660
|
+
.rowopen:hover { background:var(--surface-2); color:var(--fg); border-color:var(--line-strong); }
|
|
17661
|
+
.rowopen svg { width:14px; height:14px; }
|
|
17662
|
+
.chip { display:inline-flex; align-items:center; gap:4px; border-radius:5px; padding:2px 8px; font-size:var(--fs-sm); font-weight:500; white-space:nowrap;
|
|
17663
|
+
background:color-mix(in srgb,var(--c) 15%,transparent); color:var(--c); }
|
|
17664
|
+
/* data-resolved (not prefers-color-scheme): tracks a manual dark choice too. */
|
|
17665
|
+
:root[data-resolved="dark"] .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); }
|
|
17666
|
+
.addrow { display:flex; align-items:center; gap:7px; padding:9px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-top:1px solid var(--line); }
|
|
17667
|
+
.addrow:hover { background:var(--hover); color:var(--fg-soft); }
|
|
17668
|
+
.gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
|
|
17669
|
+
input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
|
|
17670
|
+
|
|
17671
|
+
/* ---- sites (\u7AD9\u70B9\u7BA1\u7406) ---- */
|
|
17672
|
+
.sites-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(264px, 1fr)); gap:14px; margin-top:6px; }
|
|
17673
|
+
.site-card { display:flex; flex-direction:column; gap:13px; padding:16px 16px 14px; background:var(--bg); border:1px solid var(--line);
|
|
17674
|
+
border-radius:var(--radius); box-shadow:var(--shadow-sm); cursor:pointer; transition:box-shadow .16s, border-color .16s, transform .16s; }
|
|
17675
|
+
.site-card:hover { box-shadow:var(--shadow-md); border-color:var(--line-strong); transform:translateY(-1px); }
|
|
17676
|
+
.site-card-head { display:flex; align-items:center; gap:11px; min-width:0; }
|
|
17677
|
+
.site-card .si { width:40px; height:40px; border-radius:11px; background:var(--accent-soft); color:var(--accent); display:grid; place-items:center; flex:none; }
|
|
17678
|
+
.site-card .si svg { width:20px; height:20px; }
|
|
17679
|
+
.site-card-id { min-width:0; }
|
|
17680
|
+
.site-card .slug { font-family:var(--mono); font-weight:600; font-size:14.5px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17681
|
+
.site-card .ttl { font-size:12px; color:var(--fg-soft); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17682
|
+
.site-card .ttl.muted { color:var(--muted); font-style:italic; }
|
|
17683
|
+
.site-addr { display:flex; align-items:center; gap:6px; width:100%; text-align:left; background:var(--surface); border:1px solid var(--line);
|
|
17684
|
+
border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted); font-family:var(--mono); font-size:var(--fs-xs); transition:border-color .12s, color .12s; }
|
|
17685
|
+
.site-addr svg { flex:none; }
|
|
17686
|
+
.site-addr span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17687
|
+
.site-addr:hover { border-color:var(--accent); color:var(--accent); }
|
|
17688
|
+
.site-card-meta { display:flex; align-items:center; gap:7px; font-size:12px; color:var(--muted); }
|
|
17689
|
+
.site-card-meta b { color:var(--fg-soft); font-weight:600; font-variant-numeric:tabular-nums; }
|
|
17690
|
+
.site-card-meta .dot { color:var(--line-strong); }
|
|
17691
|
+
.site-card-foot { display:flex; align-items:center; gap:6px; padding-top:12px; margin-top:1px; border-top:1px solid var(--line); }
|
|
17692
|
+
.site-card-foot .btn-secondary { padding:6px 12px; justify-content:center; }
|
|
17693
|
+
|
|
17694
|
+
.site-empty { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:48px 24px; margin-top:8px; }
|
|
17695
|
+
.site-empty .ei { font-size:30px; margin-bottom:10px; }
|
|
17696
|
+
.site-empty .et { font-size:var(--fs-body); color:var(--fg-soft); margin-bottom:4px; font-weight:600; }
|
|
17697
|
+
.site-empty .ed { font-size:var(--fs-sm); margin-bottom:16px; }
|
|
17698
|
+
|
|
17699
|
+
/* site peek: access bar + file manager */
|
|
17700
|
+
.acc-link { display:flex; align-items:center; gap:6px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius-sm); padding:7px 8px 7px 11px; margin-bottom:16px; }
|
|
17701
|
+
.acc-link .url { flex:1; font-family:var(--mono); font-size:var(--fs-sm); color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17702
|
+
.acc-link button { width:28px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--muted); flex:none; }
|
|
17703
|
+
.acc-link button:hover { background:var(--hover-2); color:var(--fg); }
|
|
17704
|
+
.acc-link button.accent { color:var(--accent); }
|
|
17705
|
+
.site-meta { display:flex; gap:18px; color:var(--muted); font-size:12px; margin-bottom:20px; }
|
|
17706
|
+
.site-meta b { color:var(--fg); font-weight:600; }
|
|
17707
|
+
.files-head { display:flex; align-items:center; justify-content:space-between; font-size:var(--fs-micro); font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin:0 0 4px; }
|
|
17708
|
+
.filerow { display:flex; align-items:center; gap:10px; padding:9px 8px; border-radius:var(--radius-sm); cursor:pointer; border-bottom:1px solid var(--line); }
|
|
17709
|
+
.filerow:last-child { border-bottom:0; }
|
|
17710
|
+
.filerow:hover { background:var(--hover); }
|
|
17711
|
+
.filerow .fi { color:var(--muted); display:grid; place-items:center; flex:none; }
|
|
17712
|
+
.filerow .fpath { flex:1; font-family:var(--mono); font-size:var(--fs-sm); color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17713
|
+
.filerow .facts { display:flex; gap:1px; opacity:0; flex:none; }
|
|
17714
|
+
.filerow:hover .facts { opacity:1; }
|
|
17715
|
+
.filerow .facts button { width:27px; height:27px; display:grid; place-items:center; border-radius:6px; color:var(--muted); }
|
|
17716
|
+
.filerow .facts button:hover { background:var(--hover-2); color:var(--fg); }
|
|
17717
|
+
.filerow .facts button.del:hover { color:var(--danger); }
|
|
17718
|
+
.enc-badge { display:inline-flex; align-items:center; font-family:var(--mono); font-size:10.5px; font-weight:500; letter-spacing:.02em;
|
|
17719
|
+
padding:2px 6px; border-radius:5px; background:var(--surface-2); color:var(--fg-soft); border:1px solid var(--line); }
|
|
17720
|
+
.enc-badge.blob { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
|
|
17721
|
+
|
|
17722
|
+
/* file preview (inside modal) */
|
|
17723
|
+
.preview-box { font-family:var(--mono); font-size:var(--fs-sm); background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius);
|
|
17724
|
+
padding:14px 16px; max-height:52vh; overflow:auto; white-space:pre-wrap; line-height:1.6; color:var(--fg); }
|
|
17725
|
+
.preview-bin { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:40px 20px; }
|
|
17726
|
+
.preview-bin .pi { font-size:28px; margin-bottom:8px; }
|
|
17727
|
+
.preview-bin .pt { color:var(--fg-soft); font-size:13px; }
|
|
17728
|
+
|
|
17729
|
+
/* in-app site preview (browser-framed iframe) */
|
|
17730
|
+
.spv-scrim { position:fixed; inset:0; background:rgba(15,15,25,.5); z-index:85; display:grid; place-items:center; padding:28px;
|
|
17731
|
+
opacity:0; pointer-events:none; transition:opacity .16s; animation:spv-in .16s forwards; }
|
|
17732
|
+
@keyframes spv-in { to { opacity:1; pointer-events:auto; } }
|
|
17733
|
+
.spv-scrim.open { opacity:1; pointer-events:auto; }
|
|
17734
|
+
.spv { width:min(1100px, 92vw); height:88vh; background:var(--bg); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg);
|
|
17735
|
+
display:flex; flex-direction:column; overflow:hidden; }
|
|
17736
|
+
.spv-chrome { display:flex; align-items:center; gap:10px; padding:9px 12px; border-bottom:1px solid var(--line); background:var(--surface); }
|
|
17737
|
+
.spv-dots { display:flex; gap:6px; flex:none; }
|
|
17738
|
+
.spv-dots i { width:11px; height:11px; border-radius:50%; background:var(--line-strong); }
|
|
17739
|
+
.spv-url { flex:1; display:flex; align-items:center; gap:6px; min-width:0; background:var(--bg); border:1px solid var(--line);
|
|
17740
|
+
border-radius:7px; padding:5px 10px; color:var(--fg-soft); font-family:var(--mono); font-size:12px; }
|
|
17741
|
+
.spv-url svg { color:var(--muted); flex:none; }
|
|
17742
|
+
.spv-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
17743
|
+
.spv-btn { display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 10px; border-radius:7px; color:var(--fg-soft); font-size:var(--fs-sm); font-weight:500; }
|
|
17744
|
+
.spv-btn:hover { background:var(--hover-2); color:var(--fg); }
|
|
17745
|
+
.spv-btn.icon { padding:6px; }
|
|
17746
|
+
/* #fff on purpose: published site content assumes a white canvas, independent
|
|
17747
|
+
of the app theme. */
|
|
17748
|
+
.spv-frame { flex:1; width:100%; border:0; background:#fff; }
|
|
17749
|
+
|
|
17750
|
+
.selbar { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:65; display:flex; align-items:center; gap:4px;
|
|
17751
|
+
background:var(--fg); color:var(--bg); padding:7px 8px 7px 14px; border-radius:11px; box-shadow:var(--shadow-lg); }
|
|
17752
|
+
.selbar .cnt { font-size:13px; font-weight:600; margin-right:6px; }
|
|
17753
|
+
.selbar button { display:flex; align-items:center; gap:6px; color:var(--bg); padding:6px 10px; border-radius:7px; font-size:13px; font-weight:500; }
|
|
17754
|
+
.selbar button:hover { background:color-mix(in srgb,var(--bg) 25%,transparent); }
|
|
17755
|
+
.selbar .del:hover { background:var(--danger); color:#fff; }
|
|
17756
|
+
|
|
17757
|
+
/* record peek */
|
|
17758
|
+
.scrim { position:fixed; inset:0; background:rgba(15,15,25,.32); z-index:70; opacity:0; pointer-events:none; transition:opacity .22s; }
|
|
17759
|
+
.scrim.open { opacity:1; pointer-events:auto; }
|
|
17760
|
+
.peek { position:fixed; top:0; right:0; width:580px; max-width:94vw; height:100%; background:var(--bg); border-left:1px solid var(--line);
|
|
17761
|
+
box-shadow:var(--shadow-lg); z-index:71; transform:translateX(100%); transition:transform .24s cubic-bezier(.32,.72,0,1); display:flex; flex-direction:column; }
|
|
17762
|
+
.peek.open { transform:none; }
|
|
17763
|
+
.peek-head { display:flex; align-items:center; gap:4px; padding:12px 16px; border-bottom:1px solid var(--line); }
|
|
17764
|
+
.peek-body { overflow:auto; padding:28px 32px; }
|
|
17765
|
+
.peek h2 { font-size:27px; font-weight:700; letter-spacing:-.02em; margin:0 0 20px; outline:none; }
|
|
17766
|
+
.proprow { display:flex; gap:8px; align-items:flex-start; margin:1px 0; }
|
|
17767
|
+
.proprow .k { width:160px; flex:none; color:var(--muted); display:flex; align-items:center; gap:7px; font-size:13px; padding:7px 8px; border-radius:var(--radius-sm); }
|
|
17768
|
+
.proprow .k:hover { background:var(--hover); }
|
|
17769
|
+
.proprow .v { flex:1; padding:7px 8px; border-radius:var(--radius-sm); cursor:text; min-height:33px; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
|
|
17770
|
+
.proprow .v:hover { background:var(--hover); }
|
|
17771
|
+
.peek-divider { height:1px; background:var(--line); margin:22px 0 18px; }
|
|
17772
|
+
|
|
17773
|
+
/* menu / popover */
|
|
17774
|
+
.menu-layer { position:fixed; inset:0; z-index:90; }
|
|
17775
|
+
.pop { position:fixed; background:var(--bg); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-lg);
|
|
17776
|
+
padding:6px; min-width:232px; max-height:70vh; overflow:auto; animation:pop .12s ease; }
|
|
17777
|
+
@keyframes pop { from { opacity:0; transform:translateY(-4px); } }
|
|
17778
|
+
/* Action-sheet variant: MenuHost (ui.tsx) adds .sheet on phones, where an
|
|
17779
|
+
anchored popover clips at the edges and sits under the finger. */
|
|
17780
|
+
.pop.sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom));
|
|
17781
|
+
min-width:0; max-height:60vh; max-height:60dvh; border-radius:var(--radius-lg);
|
|
17782
|
+
animation:sheet-in .18s ease; }
|
|
17783
|
+
@keyframes sheet-in { from { opacity:0; transform:translateY(12px); } }
|
|
17784
|
+
.pop .lbl { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
|
|
17785
|
+
.pop .item { display:flex; align-items:center; gap:10px; padding:7px 8px; border-radius:var(--radius-sm); width:100%; text-align:left; color:var(--fg); font-size:var(--fs-ui); }
|
|
17786
|
+
.pop .item:hover, .pop .item.sel { background:var(--hover-2); }
|
|
17787
|
+
.pop .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
|
|
17788
|
+
.pop .item .lico.plain { border:0; width:22px; height:22px; }
|
|
17789
|
+
.pop .item .meta { flex:1; min-width:0; }
|
|
17790
|
+
.pop .item .t { font-size:var(--fs-ui); } .pop .item .d { font-size:var(--fs-xs); color:var(--muted); display:block; }
|
|
17791
|
+
.pop .item .chk { color:var(--accent); margin-left:auto; }
|
|
17792
|
+
.pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
|
|
17793
|
+
.pop .sep { height:1px; background:var(--line); margin:5px 4px; }
|
|
17794
|
+
.pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
|
|
17795
|
+
.pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
|
|
17796
|
+
.pop .typegrid .item .lico { margin:0 auto; }
|
|
17797
|
+
.pop input.field { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:7px 9px; outline:none; background:var(--surface); margin:2px; }
|
|
17798
|
+
.pop input.field:focus { border-color:var(--accent); }
|
|
17799
|
+
.optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
|
|
17800
|
+
.optrow .chip { flex:1; }
|
|
17801
|
+
.optrow .x { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
|
|
17802
|
+
.optrow .x:hover { background:var(--hover-2); color:var(--danger); }
|
|
17803
|
+
|
|
17804
|
+
/* modal */
|
|
17805
|
+
.modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
|
|
17806
|
+
opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
|
|
17807
|
+
.modal-scrim.open { opacity:1; pointer-events:auto; }
|
|
17808
|
+
.modal { background:var(--bg); border-radius:var(--radius-xl); box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
|
17809
|
+
.modal-head { padding:20px 22px 0; }
|
|
17810
|
+
.modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
|
|
17811
|
+
.modal-head p { margin:6px 0 0; color:var(--muted); font-size:var(--fs-ui); }
|
|
17812
|
+
.modal-body { padding:18px 22px; overflow-y:auto; }
|
|
17813
|
+
.modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
|
|
17814
|
+
.field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
|
|
17815
|
+
.field-label:first-child { margin-top:0; }
|
|
17816
|
+
.text-input { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:9px 11px; outline:none; background:var(--surface); font-size:var(--fs-body); }
|
|
17817
|
+
.text-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
|
|
17818
|
+
.icon-pick { display:flex; gap:6px; flex-wrap:wrap; }
|
|
17819
|
+
.icon-pick button { width:38px; height:38px; border-radius:9px; border:1px solid var(--line); font-size:19px; display:grid; place-items:center; }
|
|
17820
|
+
.icon-pick button:hover { background:var(--hover-2); }
|
|
17821
|
+
.icon-pick button.sel { border-color:var(--accent); background:var(--accent-soft); }
|
|
17822
|
+
.tmpl { display:flex; gap:8px; }
|
|
17823
|
+
.tmpl button { flex:1; border:1px solid var(--line); border-radius:var(--radius); padding:12px; text-align:left; }
|
|
17824
|
+
.tmpl button:hover { border-color:var(--accent); background:var(--surface); }
|
|
17825
|
+
.tmpl button.sel { border-color:var(--accent); background:var(--accent-soft); }
|
|
17826
|
+
.tmpl .tt { font-weight:600; font-size:var(--fs-ui); } .tmpl .td { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; }
|
|
17827
|
+
|
|
17828
|
+
/* toast */
|
|
17829
|
+
.toasts { position:fixed; bottom:20px; left:20px; z-index:110; display:flex; flex-direction:column; gap:8px; }
|
|
17830
|
+
.toast { background:var(--fg); color:var(--bg); padding:10px 14px; border-radius:9px; font-size:13px; box-shadow:var(--shadow-lg);
|
|
17831
|
+
display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
|
|
17832
|
+
@keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
|
|
17833
|
+
|
|
17834
|
+
/* misc */
|
|
17835
|
+
.empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
|
|
17836
|
+
color:var(--muted); text-align:center; padding:40px; }
|
|
17837
|
+
|
|
17838
|
+
/* ---- empty state: \u77E5\u8BC6\u751F\u957F\u63D2\u753B ---- */
|
|
17839
|
+
.estate-art { width:240px; height:208px; overflow:visible; animation:idleFloat 5s ease-in-out 1.6s infinite; }
|
|
17840
|
+
.estate-art .stroke-a { fill:none; stroke:var(--accent); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
|
|
17841
|
+
.estate-art .stroke { fill:none; stroke:var(--line-strong); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
|
|
17842
|
+
.estate-art .fill-a { fill:var(--accent); }
|
|
17843
|
+
.estate-art .fill-soft { fill:var(--accent-soft); }
|
|
17844
|
+
.estate-art .fill-surface { fill:var(--bg); }
|
|
17845
|
+
.estate-art .ground { fill:var(--accent); }
|
|
17846
|
+
.estate-title { margin:28px 0 0; font-size:18px; font-weight:600; letter-spacing:.2px; color:var(--fg);
|
|
17847
|
+
opacity:0; animation:fadeUp .5s ease forwards 1.0s; }
|
|
17848
|
+
.estate-hint { margin:9px 0 0; font-size:var(--fs-ui); line-height:1.6; color:var(--muted); max-width:300px;
|
|
17849
|
+
opacity:0; animation:fadeUp .5s ease forwards 1.15s; }
|
|
17850
|
+
.estate-link { margin:18px 0 0; font-size:var(--fs-body); font-weight:600; color:var(--accent);
|
|
17851
|
+
display:inline-flex; align-items:center; gap:6px; padding:9px 8px; border-radius:var(--radius-sm);
|
|
17852
|
+
opacity:0; animation:fadeUp .5s ease forwards 1.3s; transition:opacity .15s; }
|
|
17853
|
+
.estate-link:hover { opacity:.7; }
|
|
17854
|
+
/* intro */
|
|
17855
|
+
.draw { stroke-dasharray:100; stroke-dashoffset:100; }
|
|
17856
|
+
@keyframes draw { to { stroke-dashoffset:0; } }
|
|
17857
|
+
@keyframes leafGrow { from { opacity:0; transform:scale(.2) rotate(-10deg); } to { opacity:1; transform:scale(1) rotate(0); } }
|
|
17858
|
+
@keyframes cardUp { from { opacity:0; transform:translateY(16px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
|
|
17859
|
+
@keyframes groundIn { to { opacity:.1; } }
|
|
17860
|
+
@keyframes glowIn { to { opacity:.6; } }
|
|
17861
|
+
@keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
|
|
17862
|
+
/* idle loops \u2014 each 0% equals the settled rest state to avoid a jump */
|
|
17863
|
+
@keyframes idleFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
|
|
17864
|
+
@keyframes plantSway { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-1.3deg); } 75% { transform:rotate(1.3deg); } }
|
|
17865
|
+
@keyframes leafSwayA { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-2.6deg); } 75% { transform:rotate(2.6deg); } }
|
|
17866
|
+
@keyframes leafSwayB { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(2.6deg); } 75% { transform:rotate(-2.6deg); } }
|
|
17867
|
+
@keyframes groundBreathe { 0%,100% { transform:scale(1); opacity:.10; } 50% { transform:scale(1.07); opacity:.15; } }
|
|
17868
|
+
@keyframes glowPulse { 0%,100% { transform:scale(1); opacity:.6; } 50% { transform:scale(1.1); opacity:.85; } }
|
|
17869
|
+
@keyframes particle { 0% { opacity:0; transform:translate(0,0) scale(.4); } 35% { opacity:.85; transform:translate(2px,-4px) scale(1); } 70% { opacity:0; transform:translate(4px,-9px) scale(.5); } 100% { opacity:0; transform:translate(0,0) scale(.4); } }
|
|
17870
|
+
/* wiring */
|
|
17871
|
+
.kg-ground { transform-box:fill-box; transform-origin:center; opacity:0;
|
|
17872
|
+
animation:groundIn .5s ease forwards .1s, groundBreathe 6s ease-in-out 1.6s infinite; }
|
|
17873
|
+
.kg-glow { filter:blur(9px); transform-box:fill-box; transform-origin:center; opacity:0;
|
|
17874
|
+
animation:glowIn .6s ease forwards .1s, glowPulse 4.6s ease-in-out 1.6s infinite; }
|
|
17875
|
+
.kg-cardB { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .2s; }
|
|
17876
|
+
.kg-cardA { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .32s; }
|
|
17877
|
+
.kg-plant { transform-box:fill-box; transform-origin:50% 100%; animation:plantSway 6s ease-in-out 1.7s infinite; }
|
|
17878
|
+
.kg-stem { animation:draw .9s ease-out forwards .4s; }
|
|
17879
|
+
.kg-leaf { transform-box:fill-box; }
|
|
17880
|
+
.kg-grow { transform-box:fill-box; opacity:0; }
|
|
17881
|
+
.kg-leaf.l1 { transform-origin:90% 100%; animation:leafSwayA 4.6s ease-in-out 1.7s infinite; }
|
|
17882
|
+
.kg-leaf.l2 { transform-origin:0% 80%; animation:leafSwayB 5.2s ease-in-out 1.8s infinite; }
|
|
17883
|
+
.kg-leaf.l3 { transform-origin:0% 100%; animation:leafSwayA 5.0s ease-in-out 1.9s infinite; }
|
|
17884
|
+
.kg-leaf.l1 .kg-grow { transform-origin:90% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards .85s; }
|
|
17885
|
+
.kg-leaf.l2 .kg-grow { transform-origin:0% 80%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.0s; }
|
|
17886
|
+
.kg-leaf.l3 .kg-grow { transform-origin:0% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.15s; }
|
|
17887
|
+
.kg-particle { transform-box:fill-box; transform-origin:center; opacity:0; }
|
|
17888
|
+
.kg-particle.p1 { animation:particle 4.5s ease-in-out 1.6s infinite; }
|
|
17889
|
+
.kg-particle.p2 { animation:particle 5.2s ease-in-out 2.0s infinite; }
|
|
17890
|
+
.kg-particle.p3 { animation:particle 4.8s ease-in-out 2.6s infinite; }
|
|
17891
|
+
.kg-particle.p4 { animation:particle 5.6s ease-in-out 3.1s infinite; }
|
|
17892
|
+
.kg-particle.p5 { animation:particle 4.2s ease-in-out 2.3s infinite; }
|
|
17893
|
+
@media (prefers-reduced-motion: reduce) {
|
|
17894
|
+
.estate-art, .estate-art *, .estate-title, .estate-hint, .estate-link { animation:none !important; opacity:1 !important; transform:none !important; }
|
|
17895
|
+
.draw { stroke-dashoffset:0 !important; }
|
|
17896
|
+
.kg-ground { opacity:.1 !important; }
|
|
17897
|
+
.kg-glow { opacity:.6 !important; }
|
|
17898
|
+
.kg-particle { opacity:0 !important; }
|
|
17899
|
+
}
|
|
17900
|
+
.error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
|
|
17901
|
+
.search-hit { margin:0 0 8px; padding:10px 14px; background:var(--surface); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
|
|
17902
|
+
.search-hit:hover { background:var(--surface-2); }
|
|
17903
|
+
.search-hit mark { background:transparent; color:var(--accent); font-weight:600; }
|
|
17904
|
+
.view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
|
|
17905
|
+
.ico.flip { transform:scaleX(-1); }
|
|
17906
|
+
|
|
17907
|
+
/* ---- board (kanban) view ---- */
|
|
17908
|
+
.board-wrap { margin-top:4px; }
|
|
17909
|
+
.board-bar { display:flex; align-items:center; gap:10px; padding:2px 0 12px; }
|
|
17910
|
+
.board { display:flex; align-items:flex-start; gap:12px; overflow-x:auto; padding-bottom:14px; min-height:240px; }
|
|
17911
|
+
.board-col { flex:0 0 272px; width:272px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); display:flex; flex-direction:column; max-height:calc(100vh - 240px); max-height:calc(100dvh - 240px); transition:box-shadow .12s,border-color .12s; }
|
|
17912
|
+
.board-col.drop-into { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); }
|
|
17913
|
+
.board-col-head { display:flex; align-items:center; gap:8px; padding:10px 10px 8px; }
|
|
17914
|
+
.board-col-head .spacer { flex:1; }
|
|
17915
|
+
.board-count { color:var(--muted); font-size:12px; font-weight:600; }
|
|
17916
|
+
.board-col-head .iconbtn.sm { width:24px; height:24px; }
|
|
17917
|
+
.board-col-body { display:flex; flex-direction:column; gap:7px; padding:0 8px 8px; overflow-y:auto; }
|
|
17918
|
+
.kcard { background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:9px 10px; box-shadow:var(--shadow-sm);
|
|
17919
|
+
cursor:grab; display:flex; flex-direction:column; gap:5px; touch-action:none; transition:border-color .1s,box-shadow .1s; }
|
|
17920
|
+
.kcard:hover { border-color:var(--line-strong); }
|
|
17921
|
+
.kcard.drag-source { opacity:.4; }
|
|
17922
|
+
.kcard.dragging-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; cursor:grabbing;
|
|
17923
|
+
box-shadow:var(--shadow-lg); opacity:.95; transform:translate3d(-9999px,0,0); }
|
|
17924
|
+
.kcard-title { font-size:var(--fs-ui); font-weight:600; line-height:1.35; word-break:break-word; }
|
|
17925
|
+
.kcard-field { display:flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--fg-soft); min-width:0; }
|
|
17926
|
+
.kcard-field svg { width:13px; height:13px; color:var(--muted); flex:none; }
|
|
17927
|
+
.board-add { display:flex; align-items:center; gap:6px; padding:7px 8px; color:var(--muted); font-size:var(--fs-sm); border-radius:var(--radius-sm); }
|
|
17928
|
+
.board-add:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
17929
|
+
|
|
17930
|
+
/* ---- calendar (month) view ---- */
|
|
17931
|
+
.cal { margin-top:4px; display:flex; flex-direction:column; }
|
|
17932
|
+
.cal-nav { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
|
|
17933
|
+
.cal-nav .spacer { flex:1; }
|
|
17934
|
+
.cal-title { font-size:15px; font-weight:650; min-width:120px; }
|
|
17935
|
+
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
|
17936
|
+
.cal-head { border:1px solid var(--line); border-bottom:0; border-radius:var(--radius) var(--radius) 0 0; background:var(--surface); }
|
|
17937
|
+
.cal-wd { padding:8px 10px; font-size:12px; font-weight:600; color:var(--muted); text-align:left; border-right:1px solid var(--line); }
|
|
17938
|
+
.cal-wd:last-child { border-right:0; }
|
|
17939
|
+
.cal-body { border:1px solid var(--line); border-radius:0 0 var(--radius) var(--radius); overflow:hidden; }
|
|
17940
|
+
.cal-week { border-bottom:1px solid var(--line); }
|
|
17941
|
+
.cal-week:last-child { border-bottom:0; }
|
|
17942
|
+
.cal-day { min-height:108px; border-right:1px solid var(--line); padding:5px 6px; display:flex; flex-direction:column; gap:4px; transition:background .1s; cursor:pointer; }
|
|
17943
|
+
.cal-day:last-child { border-right:0; }
|
|
17944
|
+
.cal-day:hover { background:var(--hover); }
|
|
17945
|
+
.cal-day.dim { background:var(--surface); color:var(--muted); }
|
|
17946
|
+
.cal-day.dim:hover { background:var(--hover); }
|
|
17947
|
+
.cal-day.drop-into { background:var(--accent-soft); }
|
|
17948
|
+
.cal-daynum { display:flex; align-items:center; justify-content:space-between; font-size:var(--fs-sm); font-weight:600; color:var(--fg-soft); }
|
|
17949
|
+
.cal-day.dim .cal-daynum { color:var(--muted); }
|
|
17950
|
+
.cal-day.today .cal-daynum span:first-child { background:var(--accent); color:var(--accent-fg); width:21px; height:21px; border-radius:50%; display:grid; place-items:center; }
|
|
17951
|
+
.cal-add { opacity:0; width:20px; height:20px; display:grid; place-items:center; color:var(--muted); border-radius:5px; transition:opacity .12s; }
|
|
17952
|
+
.cal-day:hover .cal-add { opacity:1; }
|
|
17953
|
+
.cal-add:hover { background:var(--hover-2); color:var(--fg); }
|
|
17954
|
+
.cal-events { display:flex; flex-direction:column; gap:3px; }
|
|
17955
|
+
.cal-ev { font-size:12px; padding:2px 7px; border-radius:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:grab; touch-action:none;
|
|
17956
|
+
background:color-mix(in srgb,var(--c) 16%,transparent); color:var(--c); border-left:2px solid var(--c); }
|
|
17957
|
+
.cal-ev:hover { filter:brightness(1.04); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c) 40%,transparent); }
|
|
17958
|
+
:root[data-resolved="dark"] .cal-ev { background:color-mix(in srgb,var(--c) 24%,transparent); color:color-mix(in srgb,var(--c) 78%,#fff); }
|
|
17959
|
+
.cal-more { display:block; width:100%; text-align:left; font-size:var(--fs-xs); color:var(--muted); padding:2px 7px; border-radius:5px; }
|
|
17960
|
+
.cal-more:hover { background:var(--hover-2); color:var(--fg-soft); }
|
|
17961
|
+
|
|
17962
|
+
/* ---- timeline (gantt) view ---- */
|
|
17963
|
+
.tl { margin-top:4px; }
|
|
17964
|
+
.tl-toolbar { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
|
|
17965
|
+
.tl-scroll { overflow:auto; border:1px solid var(--line); border-radius:var(--radius); max-height:calc(100vh - 230px); max-height:calc(100dvh - 230px); }
|
|
17966
|
+
.tl-canvas { position:relative; }
|
|
17967
|
+
.tl-axis { position:sticky; top:0; z-index:4; background:var(--surface); border-bottom:1px solid var(--line); }
|
|
17968
|
+
.tl-corner { position:sticky; left:0; z-index:5; height:100%; display:flex; align-items:center; padding:0 12px; font-size:12px; font-weight:600; color:var(--muted);
|
|
17969
|
+
background:var(--surface); border-right:1px solid var(--line); }
|
|
17970
|
+
.tl-tick { position:absolute; top:0; height:100%; box-sizing:border-box; border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:flex-end; padding:0 0 4px 4px; }
|
|
17971
|
+
.tl-tick.mstart { border-right-color:var(--line-strong); }
|
|
17972
|
+
.tl-tick .tl-month { position:absolute; top:5px; left:4px; font-size:10.5px; font-weight:700; color:var(--fg-soft); white-space:nowrap; }
|
|
17973
|
+
.tl-tick .tl-dnum { font-size:var(--fs-micro); color:var(--muted); }
|
|
17974
|
+
.tl-tick.today .tl-dnum { color:var(--accent); font-weight:700; }
|
|
17975
|
+
.tl-row { position:absolute; left:0; right:0; border-bottom:1px solid var(--line); }
|
|
17976
|
+
.tl-rowlabel { position:sticky; left:0; z-index:3; height:100%; display:flex; align-items:center; padding:0 12px; font-size:13px; font-weight:500;
|
|
17977
|
+
background:var(--bg); border-right:1px solid var(--line); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
17978
|
+
.tl-item { position:absolute; top:7px; height:22px; box-sizing:border-box; background:var(--accent); color:var(--accent-fg); border-radius:6px;
|
|
17979
|
+
display:flex; align-items:center; gap:2px; padding:0 4px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); overflow:hidden; }
|
|
17980
|
+
.tl-item .tl-label { flex:1; font-size:12px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
|
|
17981
|
+
.tl-row:hover .tl-item { box-shadow:0 0 0 1px var(--accent), var(--shadow-sm); }
|
|
17982
|
+
.tl-item .tl-h { width:8px; align-self:stretch; flex:none; cursor:col-resize; border-radius:6px; opacity:0; transition:opacity .1s; }
|
|
17983
|
+
.tl-item:hover .tl-h { opacity:1; background:rgba(255,255,255,.35); }
|
|
17984
|
+
.tl-item .tl-h:hover { background:rgba(255,255,255,.6); }
|
|
17985
|
+
.tl-milestone { position:absolute; top:9px; width:18px; height:18px; transform:rotate(45deg); background:var(--accent); border-radius:3px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); margin-left:-9px; }
|
|
17986
|
+
.tl-milestone:hover { box-shadow:0 0 0 2px var(--accent-soft), var(--shadow-sm); }
|
|
17987
|
+
.tl-track-empty { position:absolute; top:0; right:0; height:100%; cursor:copy; }
|
|
17988
|
+
.tl-track-empty:hover { background:var(--hover); }
|
|
17989
|
+
.tl-schedule { position:sticky; top:7px; height:22px; display:inline-flex; align-items:center; padding:0 10px; border-radius:6px; font-size:12px;
|
|
17990
|
+
color:var(--muted); border:1px dashed var(--line-strong); background:var(--bg); white-space:nowrap; }
|
|
17991
|
+
.tl-schedule:hover { color:var(--accent); border-color:var(--accent); }
|
|
17992
|
+
.tl-today { position:absolute; top:0; width:2px; background:var(--accent); opacity:.55; z-index:2; pointer-events:none; }
|
|
17993
|
+
.tl-guide { position:absolute; top:0; width:0; border-left:1px dashed var(--accent); z-index:6; pointer-events:none; }
|
|
17994
|
+
.tl-guide-label { position:absolute; top:2px; left:4px; font-size:var(--fs-micro); font-weight:600; color:var(--accent-fg); background:var(--accent);
|
|
17995
|
+
padding:1px 6px; border-radius:5px; white-space:nowrap; box-shadow:var(--shadow-sm); }
|
|
17996
|
+
|
|
17997
|
+
/* mobile */
|
|
17998
|
+
/* Gate on a coarse pointer, not width alone: a narrow *desktop* window keeps
|
|
17999
|
+
the desktop layout/sizes (no jarring snap to 16px fonts + 40px buttons when
|
|
18000
|
+
it crosses 768px), while phones/tablets (and DevTools device mode, which
|
|
18001
|
+
emulates coarse) get the touch treatment. JS useIsMobile mirrors this query. */
|
|
18002
|
+
@media (max-width: 768px) and (pointer: coarse) {
|
|
18003
|
+
/* Full-page navigation, document-scrolled: the active view (home sidebar
|
|
18004
|
+
or the picked content view, via body.mobile-content from app.tsx) sits
|
|
18005
|
+
in normal flow and the *document* scrolls; the inactive view is
|
|
18006
|
+
display:none. This is structural for iOS 26 Safari: its liquid-glass
|
|
18007
|
+
bars only composite pixels from the document's scrolled canvas \u2014 a
|
|
18008
|
+
fixed pane with an inner scroller never paints under the bottom bar,
|
|
18009
|
+
so the chrome degrades to a tint sampled once and frozen (opaque pane
|
|
18010
|
+
bg) or fallback white (transparent pane); no see-through, and theme
|
|
18011
|
+
switches never propagate. In-flow content gets both for free: glass
|
|
18012
|
+
blurs live pixels, colors follow theme/navigation in real time.
|
|
18013
|
+
(app.tsx owns the scroll handoff between the two views.) */
|
|
18014
|
+
body { overflow:auto; }
|
|
18015
|
+
#app { display:block; height:auto; }
|
|
18016
|
+
.sidebar { width:100% !important; min-height:100dvh; margin-left:0 !important;
|
|
18017
|
+
border-right:0; transition:none; }
|
|
18018
|
+
body.mobile-content .sidebar { display:none; }
|
|
18019
|
+
.main { display:none; }
|
|
18020
|
+
body.mobile-content .main { display:flex; min-height:100dvh; overflow:visible;
|
|
18021
|
+
animation:page-in .22s cubic-bezier(.4,0,.2,1); }
|
|
18022
|
+
.sb-scroll { flex:none; overflow:visible; }
|
|
18023
|
+
.content { flex:none; overflow:visible; }
|
|
18024
|
+
/* Keep the back-button header pinned while the document scrolls. Its
|
|
18025
|
+
background must sit on the sticky box itself: the status-bar tint is
|
|
18026
|
+
sampled from edge-touching sticky elements' own background-color \u2014 a
|
|
18027
|
+
transparent box samples as a fallback white. Each home\u2194content switch
|
|
18028
|
+
toggles the pane's display, a real render-tree entry, so the tint
|
|
18029
|
+
re-samples on navigation. */
|
|
18030
|
+
.topbar { position:sticky; top:0; z-index:30; background:var(--bg); }
|
|
18031
|
+
/* With no sticky element at an edge (home top, all bottoms) the chrome
|
|
18032
|
+
derives from the html/body backgrounds and the scrolled canvas \u2014 keep
|
|
18033
|
+
all of them tracking the active surface so bars and overscroll blend. */
|
|
18034
|
+
html:has(body.mobile), body.mobile { background:var(--sidebar); }
|
|
18035
|
+
html:has(body.mobile-content), body.mobile-content { background:var(--bg); }
|
|
18036
|
+
.sb-head .iconbtn { display:none; } /* no collapse on mobile */
|
|
18037
|
+
.sb-resizer { display:none; }
|
|
18038
|
+
.topbar .hamburger { display:grid; } /* serves as the back button */
|
|
18039
|
+
|
|
18040
|
+
/* Sites/settings move from the bottom footer rows up into the home header
|
|
18041
|
+
as icon buttons \u2014 no full-width strip eating into the list + safe area. */
|
|
18042
|
+
.sb-footer { display:none; }
|
|
18043
|
+
.sb-act { display:grid; place-items:center; width:40px; height:40px; flex:none;
|
|
18044
|
+
border-radius:var(--radius-sm); color:var(--fg-soft); position:relative; }
|
|
18045
|
+
.sb-act:active, .sb-act.active { background:var(--hover-2); color:var(--fg); }
|
|
18046
|
+
.sb-act .ico { width:20px; height:20px; }
|
|
18047
|
+
.sb-act .nav-dot { position:absolute; top:7px; right:7px; box-shadow:0 0 0 2px var(--sidebar); }
|
|
18048
|
+
|
|
18049
|
+
/* Phone typography: bigger text for a small screen held close. Tap-target
|
|
18050
|
+
sizing and the 16px iOS anti-zoom inputs live in the width-independent
|
|
18051
|
+
(pointer:coarse) block below \u2014 tablets need those too. */
|
|
18052
|
+
.navitem { padding:9px 8px; font-size:16px; }
|
|
18053
|
+
.navitem .emoji { font-size:18px; }
|
|
18054
|
+
.navitem .tw { width:26px; height:26px; }
|
|
18055
|
+
.sb-section-head { font-size:12px; }
|
|
18056
|
+
.sb-search kbd { display:none; } /* no hardware keyboard, no shortcut hint */
|
|
18057
|
+
/* Search box is hidden until the header search button reveals it; it then
|
|
18058
|
+
slides+fades down in. Desktop base (.sb-search{display:flex}) is untouched,
|
|
18059
|
+
so search stays always-on there. reduced-motion is handled globally. */
|
|
18060
|
+
.sb-search { display:none; }
|
|
18061
|
+
.sb-search.open { display:flex; animation:search-reveal .22s cubic-bezier(.32,.72,0,1); }
|
|
18062
|
+
@keyframes search-reveal { from { opacity:0; transform:translateY(-8px); } }
|
|
18063
|
+
.btn { font-size:15px; }
|
|
18064
|
+
.crumb { font-size:16px; }
|
|
18065
|
+
.pop .item { font-size:16px; }
|
|
18066
|
+
.pop .item .t { font-size:16px; }
|
|
18067
|
+
|
|
18068
|
+
/* Document: bigger body text, tighter side padding for narrow screens. */
|
|
18069
|
+
.doc { padding:24px 18px calc(36vh + env(safe-area-inset-bottom)); }
|
|
18070
|
+
.doc-title { font-size:30px; }
|
|
18071
|
+
.doc .editable { font-size:16px; }
|
|
18072
|
+
/* 16px (not 14): a focused code textarea below 16px makes iOS auto-zoom. */
|
|
18073
|
+
.codeblock { --code-fs:16px; }
|
|
18074
|
+
.block .gutter { display:none; }
|
|
18075
|
+
/* Anchor/TOC jumps: clear the mobile topbar (49px min + notch inset);
|
|
18076
|
+
the desktop 60px estimate leaves blocks half-hidden behind it. */
|
|
18077
|
+
.block { scroll-margin-top:calc(49px + env(safe-area-inset-top) + 8px); }
|
|
18078
|
+
.db { padding:16px 14px 90px; }
|
|
18079
|
+
.peek { width:100%; }
|
|
18080
|
+
|
|
18081
|
+
/* Safe areas (viewport-fit=cover): keep chrome clear of the notch / home bar. */
|
|
18082
|
+
.sb-head { padding-top:calc(14px + env(safe-area-inset-top)); }
|
|
18083
|
+
.topbar { padding-top:calc(10px + env(safe-area-inset-top)); }
|
|
18084
|
+
/* no footer on mobile, so the list itself clears the home indicator */
|
|
18085
|
+
.sb-scroll { padding-bottom:calc(24px + env(safe-area-inset-bottom)); }
|
|
18086
|
+
|
|
18087
|
+
/* Overlays: dialogs become bottom sheets (centered cards fight the soft
|
|
18088
|
+
keyboard), and fixed-bottom chrome stays clear of the home indicator. */
|
|
18089
|
+
.modal-scrim { align-items:flex-end; padding:0; }
|
|
18090
|
+
.modal { width:100%; max-height:92vh; max-height:92dvh;
|
|
18091
|
+
border-radius:var(--radius-xl) var(--radius-xl) 0 0;
|
|
18092
|
+
padding-bottom:env(safe-area-inset-bottom);
|
|
18093
|
+
animation:sheet-up .22s cubic-bezier(.32,.72,0,1); }
|
|
18094
|
+
.toasts { left:12px; right:12px; bottom:calc(12px + env(safe-area-inset-bottom)); }
|
|
18095
|
+
.toast { justify-content:center; }
|
|
18096
|
+
.selbar { bottom:calc(16px + env(safe-area-inset-bottom)); max-width:calc(100vw - 24px); }
|
|
18097
|
+
}
|
|
18098
|
+
@keyframes sheet-up { from { transform:translateY(100%); } }
|
|
18099
|
+
/* In-flow stand-in for the old two-pane slide: only the entering view
|
|
18100
|
+
animates (the other is display:none, document-scroll layout above). */
|
|
18101
|
+
@keyframes page-in { from { transform:translateX(28px); opacity:0; } }
|
|
18102
|
+
|
|
18103
|
+
/* Touch ergonomics, decoupled from width: tablets (iPad portrait is 820px+)
|
|
18104
|
+
keep the desktop layout above but still tap with fingers, so target sizes,
|
|
18105
|
+
drag handles and the iOS anti-zoom input size key off the pointer alone. */
|
|
18106
|
+
@media (pointer: coarse) {
|
|
18107
|
+
/* iOS Safari auto-zooms the page when a focused field is below 16px; the
|
|
18108
|
+
class selectors out-specify the element-level sizes set on these. */
|
|
18109
|
+
input, textarea, .text-input, .doc-source, input.inlineedit { font-size:16px; }
|
|
18110
|
+
.navitem .acts button { width:34px; height:34px; }
|
|
18111
|
+
.iconbtn { width:40px; height:40px; }
|
|
18112
|
+
.btn { padding:9px 14px; min-height:40px; }
|
|
18113
|
+
.pop .item { padding:11px 10px; }
|
|
18114
|
+
/* Column/sidebar resize handles: 7px is a mouse affordance, not a finger's. */
|
|
18115
|
+
.col-resizer, .doc-col-resizer, .sb-resizer { width:16px; right:-8px; }
|
|
18116
|
+
}
|
|
18117
|
+
|
|
18118
|
+
/* No hover available (touch): controls that only materialize on :hover are
|
|
18119
|
+
unreachable, so pin them visible. Width-independent on purpose \u2014 this is
|
|
18120
|
+
the tablet's lifeline too. Desktops with a mouse are untouched. */
|
|
18121
|
+
@media (hover: none) {
|
|
18122
|
+
.code-tools { opacity:1; transform:none; pointer-events:auto; }
|
|
18123
|
+
.rowactions { opacity:1; pointer-events:auto; }
|
|
18124
|
+
.doc-th .doc-col-menu, .cal-add, .filerow .facts,
|
|
18125
|
+
.navitem .acts, .sb-section-head .add { opacity:1; }
|
|
18126
|
+
table.doc-table .doc-row-del { opacity:1; }
|
|
18127
|
+
.selcell input { opacity:1; }
|
|
18128
|
+
.rowgrip { color:var(--muted); }
|
|
18129
|
+
.tl-item .tl-h { opacity:1; background:rgba(255,255,255,.35); }
|
|
18130
|
+
/* Neutralize the stickiest tap-residue tints (a tap "hovers" until the
|
|
18131
|
+
next tap elsewhere); transient menus/buttons are left alone. */
|
|
18132
|
+
.navitem:hover { background:none; }
|
|
18133
|
+
.iconbtn:hover { background:none; }
|
|
18134
|
+
tbody tr:hover { background:none; }
|
|
18135
|
+
td.cell-td:hover { background:none; }
|
|
18136
|
+
.filerow:hover { background:none; }
|
|
18137
|
+
.cal-day:hover { background:none; }
|
|
18138
|
+
.cal-day.dim:hover { background:var(--surface); }
|
|
18139
|
+
}
|
|
18140
|
+
|
|
18141
|
+
/* Quick Notes window (desktop, loaded at #quick). A transparent body lets the
|
|
18142
|
+
macOS vibrancy blur show through; a translucent panel keeps text readable.
|
|
18143
|
+
The top bar is the window drag region; its buttons opt back out. */
|
|
18144
|
+
body.quicknote { background:transparent; }
|
|
18145
|
+
/* The main app lays #app out as a flex row (sidebar + main); the quick-note
|
|
18146
|
+
route has a single child, so override to a full-bleed block or the panel
|
|
18147
|
+
shrinks to its content width and the window's vibrancy shows through. */
|
|
18148
|
+
body.quicknote #app { display:block; width:100vw; height:100vh; height:100dvh; }
|
|
18149
|
+
.qn { display:flex; flex-direction:column; width:100%; height:100vh; height:100dvh; overflow:hidden;
|
|
18150
|
+
background:color-mix(in srgb, var(--bg) 20%, transparent); }
|
|
18151
|
+
.qn-bar { display:flex; align-items:center; gap:6px; padding:4px 10px 4px 76px;
|
|
18152
|
+
user-select:none; -webkit-app-region:drag;
|
|
18153
|
+
background:color-mix(in srgb, var(--surface) 30%, transparent); }
|
|
18154
|
+
.qn-brand { flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--fg-soft);
|
|
18155
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
18156
|
+
.qn-actions { display:flex; gap:2px; -webkit-app-region:no-drag; }
|
|
18157
|
+
.qn-actions .iconbtn { width:26px; height:26px; }
|
|
18158
|
+
.qn-actions .iconbtn.active { color:var(--accent); background:var(--hover-2); }
|
|
18159
|
+
.qn-body { flex:1; overflow:auto; -webkit-app-region:no-drag; }
|
|
18160
|
+
.qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
|
|
18161
|
+
.qn-body .doc-title { font-size:24px; margin-bottom:2px; }
|
|
18162
|
+
.qn-body .doc-meta { display:none; }
|
|
18163
|
+
/* A native checkbox paints an opaque white box when unchecked, which breaks
|
|
18164
|
+
the window's vibrancy blur. Draw a custom box so the unchecked background
|
|
18165
|
+
stays transparent; checked falls back to the accent fill + checkmark. */
|
|
18166
|
+
body.quicknote .b-todo .marker input {
|
|
18167
|
+
appearance:none; -webkit-appearance:none; box-sizing:border-box;
|
|
18168
|
+
border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
|
|
18169
|
+
border-radius:4px; background:transparent; }
|
|
18170
|
+
body.quicknote .b-todo .marker input:checked {
|
|
18171
|
+
border-color:var(--accent);
|
|
18172
|
+
background:var(--accent) center/10px no-repeat
|
|
18173
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
|
|
18174
|
+
/* Compact menus inside the quick-note window only (dropdown + slash command
|
|
18175
|
+
share .pop/.item/.lico with the main app; scope under body.quicknote so the
|
|
18176
|
+
main app keeps its roomier density). */
|
|
18177
|
+
body.quicknote .pop { padding:5px; border-radius:9px; }
|
|
18178
|
+
body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
|
|
18179
|
+
body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:var(--fs-sm); }
|
|
18180
|
+
body.quicknote .pop .item .t { font-size:var(--fs-sm); }
|
|
18181
|
+
body.quicknote .pop .item .d { font-size:10.5px; }
|
|
18182
|
+
body.quicknote .pop .item .lico { width:22px; height:22px; }
|
|
18183
|
+
body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
|
|
18184
|
+
body.quicknote .pop .sep { margin:4px 4px; }
|
|
18185
|
+
|
|
18186
|
+
/* Quick Notes settings rows (desktop only). */
|
|
18187
|
+
.qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
|
|
18188
|
+
.qn-set-row:first-of-type { border-top:0; }
|
|
18189
|
+
.qn-set-main { flex:1; min-width:0; }
|
|
18190
|
+
.qn-set-name { font-weight:600; }
|
|
18191
|
+
.qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
|
|
18192
|
+
.qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
|
|
18193
|
+
.qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
|
|
18194
|
+
`;
|
|
18195
|
+
|
|
18196
|
+
// src/webui/server/assets.ts
|
|
18197
|
+
var RUNNING_FROM_SOURCE = import.meta.url.includes("/src/webui/");
|
|
18198
|
+
var HTML = `<!doctype html>
|
|
18199
|
+
<html lang="zh">
|
|
18200
|
+
<head>
|
|
18201
|
+
<meta charset="utf-8" />
|
|
18202
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
18203
|
+
<!-- Mobile browser chrome (status/address bar) tints to theme-color; app.tsx
|
|
18204
|
+
keeps #theme-color-meta in sync with the active theme + visible surface. -->
|
|
18205
|
+
<meta name="theme-color" id="theme-color-meta" content="#ffffff" />
|
|
18206
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
18207
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
18208
|
+
<title>Metahub</title>
|
|
18209
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
18210
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
18211
|
+
<!-- Load web fonts without blocking first paint: media="print" keeps the
|
|
18212
|
+
stylesheet non-render-blocking, then onload flips it to "all". The page
|
|
18213
|
+
paints instantly with the system-font fallbacks baked into --ui/--mono and
|
|
18214
|
+
swaps to the web fonts when they arrive (or stays on system fonts offline).
|
|
18215
|
+
This is what kept the desktop cold-start white for seconds on slow networks. -->
|
|
18216
|
+
<link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
|
18217
|
+
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"></noscript>
|
|
18218
|
+
<!-- FOUC guard: resolve the stored choice (system \u2192 OS preference) to an
|
|
18219
|
+
explicit data-resolved before first paint. CSS keys off data-resolved
|
|
18220
|
+
alone, so the dark palette exists exactly once \u2014 see styles.css. Dark also
|
|
18221
|
+
pre-tints the status bar (hex mirrors --bg) so the first frame isn't white. -->
|
|
18222
|
+
<script>try{var t=localStorage.getItem('mh-theme'),d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.dataset.resolved=d?'dark':'light';if(d)document.getElementById('theme-color-meta').content='#1a1a1c'}catch(e){}</script>
|
|
18223
|
+
<link rel="stylesheet" href="/webui.css">
|
|
18224
|
+
</head>
|
|
18225
|
+
<body>
|
|
18226
|
+
<div id="app"></div>
|
|
18227
|
+
<script type="module" src="/webui.js"></script>
|
|
18228
|
+
</body>
|
|
18229
|
+
</html>`;
|
|
18230
|
+
var injectedJs = null;
|
|
18231
|
+
function setWebuiBundle(js) {
|
|
18232
|
+
injectedJs = js;
|
|
18233
|
+
}
|
|
18234
|
+
var cachedJs = null;
|
|
18235
|
+
var cachedJsMtime = 0;
|
|
18236
|
+
function newestSourceMtime(dir) {
|
|
18237
|
+
let newest = 0;
|
|
18238
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
18239
|
+
if (e.name === "server" || e.name.startsWith("."))
|
|
18240
|
+
continue;
|
|
18241
|
+
const p = join3(dir, e.name);
|
|
18242
|
+
if (e.isDirectory())
|
|
18243
|
+
newest = Math.max(newest, newestSourceMtime(p));
|
|
18244
|
+
else if (/\.tsx?$/.test(e.name))
|
|
18245
|
+
newest = Math.max(newest, statSync(p).mtimeMs);
|
|
18246
|
+
}
|
|
18247
|
+
return newest;
|
|
18248
|
+
}
|
|
18249
|
+
async function getJs() {
|
|
18250
|
+
if (injectedJs != null)
|
|
18251
|
+
return injectedJs;
|
|
18252
|
+
if (RUNNING_FROM_SOURCE) {
|
|
18253
|
+
const srcDir = fileURLToPath(new URL("..", import.meta.url));
|
|
18254
|
+
const newest = newestSourceMtime(srcDir);
|
|
18255
|
+
if (cachedJs == null || newest > cachedJsMtime) {
|
|
18256
|
+
const entry = fileURLToPath(new URL("../app.tsx", import.meta.url));
|
|
18257
|
+
const res = await Bun.build({ entrypoints: [entry], target: "browser" });
|
|
18258
|
+
if (!res.success)
|
|
18259
|
+
throw new AggregateError(res.logs, "webui build failed");
|
|
18260
|
+
cachedJs = await res.outputs[0].text();
|
|
18261
|
+
cachedJsMtime = newest;
|
|
18262
|
+
}
|
|
18263
|
+
return cachedJs;
|
|
18264
|
+
}
|
|
18265
|
+
if (cachedJs == null) {
|
|
18266
|
+
const prebuilt = Bun.file(fileURLToPath(new URL("./webui.js", import.meta.url)));
|
|
18267
|
+
if (!await prebuilt.exists()) {
|
|
18268
|
+
throw new Error("webui bundle missing: dist/webui.js was not built \u2014 run `bun run build`");
|
|
18269
|
+
}
|
|
18270
|
+
cachedJs = await prebuilt.text();
|
|
18271
|
+
}
|
|
18272
|
+
return cachedJs;
|
|
18273
|
+
}
|
|
18274
|
+
async function getCss() {
|
|
18275
|
+
if (RUNNING_FROM_SOURCE) {
|
|
18276
|
+
return Bun.file(fileURLToPath(new URL("../styles.css", import.meta.url))).text();
|
|
18277
|
+
}
|
|
18278
|
+
return styles_default;
|
|
18279
|
+
}
|
|
18280
|
+
async function warmWebui() {
|
|
18281
|
+
try {
|
|
18282
|
+
await getJs();
|
|
18283
|
+
} catch {}
|
|
18284
|
+
}
|
|
18285
|
+
function asset(body, contentType) {
|
|
18286
|
+
return new Response(body, {
|
|
18287
|
+
headers: { "content-type": contentType, "cache-control": "no-store" }
|
|
18288
|
+
});
|
|
18289
|
+
}
|
|
18290
|
+
async function serveWebui(req) {
|
|
18291
|
+
if (req.method !== "GET")
|
|
18292
|
+
return null;
|
|
18293
|
+
const { pathname } = new URL(req.url);
|
|
18294
|
+
if (pathname === "/")
|
|
18295
|
+
return asset(HTML, "text/html; charset=utf-8");
|
|
18296
|
+
if (pathname === "/webui.css")
|
|
18297
|
+
return asset(await getCss(), "text/css; charset=utf-8");
|
|
18298
|
+
if (pathname === "/webui.js") {
|
|
18299
|
+
try {
|
|
18300
|
+
return asset(await getJs(), "text/javascript; charset=utf-8");
|
|
18301
|
+
} catch (e) {
|
|
18302
|
+
console.error("[webui] failed to serve /webui.js \u2014", e);
|
|
18303
|
+
return new Response(`webui build failed: ${e}`, { status: 500 });
|
|
18304
|
+
}
|
|
18305
|
+
}
|
|
18306
|
+
return null;
|
|
18307
|
+
}
|
|
18308
|
+
// src/webui/server/routes.ts
|
|
18309
|
+
init_errors();
|
|
18310
|
+
|
|
18311
|
+
// src/core/history.ts
|
|
18312
|
+
init_crdt();
|
|
18313
|
+
init_errors();
|
|
18314
|
+
var REVISION_GAP_MS = 1500;
|
|
18315
|
+
function hlcMillis(hlc2) {
|
|
18316
|
+
return Number(hlc2.slice(0, 15));
|
|
18317
|
+
}
|
|
18318
|
+
function hlcIso(hlc2) {
|
|
18319
|
+
return new Date(hlcMillis(hlc2)).toISOString();
|
|
18320
|
+
}
|
|
18321
|
+
function clusterRevisions(changes) {
|
|
18322
|
+
const groups = [];
|
|
18323
|
+
let cur = [];
|
|
18324
|
+
for (const c of changes) {
|
|
18325
|
+
const prev = cur[cur.length - 1];
|
|
18326
|
+
if (prev && (prev.node_id !== c.node_id || hlcMillis(c.hlc) - hlcMillis(prev.hlc) > REVISION_GAP_MS)) {
|
|
18327
|
+
groups.push(cur);
|
|
18328
|
+
cur = [];
|
|
18329
|
+
}
|
|
18330
|
+
cur.push(c);
|
|
18331
|
+
}
|
|
18332
|
+
if (cur.length)
|
|
18333
|
+
groups.push(cur);
|
|
18334
|
+
return groups;
|
|
18335
|
+
}
|
|
18336
|
+
function flagSet(value) {
|
|
18337
|
+
return value !== null && value !== "0" && value !== "false" && value !== "null";
|
|
18338
|
+
}
|
|
18339
|
+
function registersAt(changes, at) {
|
|
18340
|
+
const out = new Map;
|
|
18341
|
+
for (const c of changes) {
|
|
18342
|
+
if (c.hlc > at)
|
|
18343
|
+
continue;
|
|
18344
|
+
const cur = out.get(c.col);
|
|
18345
|
+
if (!cur || c.hlc > cur.hlc)
|
|
18346
|
+
out.set(c.col, { value: c.value, hlc: c.hlc });
|
|
18347
|
+
}
|
|
18348
|
+
return out;
|
|
18349
|
+
}
|
|
18350
|
+
function rowChanges(db2, dataset, rowId) {
|
|
18351
|
+
return db2.query("SELECT hlc, node_id, dataset, row_id, col, value FROM crdt_changes WHERE dataset = ? AND row_id = ? ORDER BY hlc").all(dataset, rowId);
|
|
18352
|
+
}
|
|
18353
|
+
function everBlockIds(db2, docId) {
|
|
18354
|
+
return db2.query("SELECT DISTINCT row_id FROM crdt_changes WHERE dataset = 'doc_blocks' AND col = 'doc_id' AND value = ?").all(JSON.stringify(docId)).map((r) => r.row_id);
|
|
18355
|
+
}
|
|
18356
|
+
function docChanges(db2, docId) {
|
|
18357
|
+
const blocks2 = everBlockIds(db2, docId);
|
|
18358
|
+
const placeholders = blocks2.map(() => "?").join(",");
|
|
18359
|
+
const blockClause = blocks2.length ? ` OR (dataset = 'doc_blocks' AND row_id IN (${placeholders}))` : "";
|
|
18360
|
+
return db2.query(`SELECT hlc, node_id, dataset, row_id, col, value FROM crdt_changes
|
|
18361
|
+
WHERE (dataset = 'documents' AND row_id = ?)${blockClause}
|
|
18362
|
+
ORDER BY hlc`).all(docId, ...blocks2);
|
|
18363
|
+
}
|
|
18364
|
+
function listDocumentRevisions(db2, id) {
|
|
18365
|
+
const changes = docChanges(db2, id);
|
|
18366
|
+
if (!changes.length)
|
|
18367
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
18368
|
+
return clusterRevisions(changes).map((group) => {
|
|
18369
|
+
const changedBlocks = new Set;
|
|
18370
|
+
const deletedBlocks = new Set;
|
|
18371
|
+
let created = false;
|
|
18372
|
+
let deleted = false;
|
|
18373
|
+
let titleChanged = false;
|
|
18374
|
+
for (const c of group) {
|
|
18375
|
+
if (c.dataset === "documents") {
|
|
18376
|
+
if (c.col === "created_hlc")
|
|
18377
|
+
created = true;
|
|
18378
|
+
else if (c.col === "title")
|
|
18379
|
+
titleChanged = true;
|
|
18380
|
+
else if (c.col === "__deleted")
|
|
18381
|
+
deleted = flagSet(c.value);
|
|
18382
|
+
} else if (c.col === "__deleted" && flagSet(c.value)) {
|
|
18383
|
+
deletedBlocks.add(c.row_id);
|
|
18384
|
+
} else if (c.col === "text") {
|
|
18385
|
+
changedBlocks.add(c.row_id);
|
|
18386
|
+
}
|
|
18387
|
+
}
|
|
18388
|
+
const last = group[group.length - 1];
|
|
18389
|
+
return {
|
|
18390
|
+
version: last.hlc,
|
|
18391
|
+
at: hlcIso(last.hlc),
|
|
18392
|
+
node_id: last.node_id,
|
|
18393
|
+
changes: group.length,
|
|
18394
|
+
created,
|
|
18395
|
+
deleted,
|
|
18396
|
+
title_changed: titleChanged,
|
|
18397
|
+
blocks_changed: changedBlocks.size,
|
|
18398
|
+
blocks_deleted: deletedBlocks.size
|
|
18399
|
+
};
|
|
18400
|
+
}).reverse();
|
|
18401
|
+
}
|
|
18402
|
+
function documentAtVersion(db2, id, at) {
|
|
18403
|
+
if (!at)
|
|
18404
|
+
throw new MhError("invalid_input", "missing version cutoff");
|
|
18405
|
+
const changes = docChanges(db2, id).filter((c) => c.hlc <= at);
|
|
18406
|
+
if (!changes.length)
|
|
18407
|
+
throw new MhError("not_found", `no version of document ${id} at or before ${at}`);
|
|
18408
|
+
const docRegs = registersAt(changes.filter((c) => c.dataset === "documents"), at);
|
|
18409
|
+
const parse5 = (col) => {
|
|
18410
|
+
const r = docRegs.get(col);
|
|
18411
|
+
return r?.value == null ? undefined : JSON.parse(r.value);
|
|
18412
|
+
};
|
|
18413
|
+
const byBlock = new Map;
|
|
18414
|
+
for (const c of changes) {
|
|
18415
|
+
if (c.dataset !== "doc_blocks")
|
|
18416
|
+
continue;
|
|
18417
|
+
(byBlock.get(c.row_id) ?? byBlock.set(c.row_id, []).get(c.row_id)).push(c);
|
|
18418
|
+
}
|
|
18419
|
+
const blocks2 = [];
|
|
18420
|
+
for (const [blockId, blockChanges] of byBlock) {
|
|
18421
|
+
const regs = registersAt(blockChanges, at);
|
|
18422
|
+
const reg = (col) => {
|
|
18423
|
+
const r = regs.get(col);
|
|
18424
|
+
return r?.value == null ? undefined : JSON.parse(r.value);
|
|
18425
|
+
};
|
|
18426
|
+
if (reg("doc_id") !== id)
|
|
18427
|
+
continue;
|
|
18428
|
+
if (regs.get("__deleted") && flagSet(regs.get("__deleted").value))
|
|
18429
|
+
continue;
|
|
18430
|
+
const text = reg("text");
|
|
18431
|
+
if (typeof text !== "string" || !text)
|
|
18432
|
+
continue;
|
|
18433
|
+
blocks2.push({
|
|
18434
|
+
id: blockId,
|
|
18435
|
+
text,
|
|
18436
|
+
order_key: reg("order_key") ?? null,
|
|
18437
|
+
blankAfter: reg("blank_after") ?? 0
|
|
18438
|
+
});
|
|
18439
|
+
}
|
|
18440
|
+
blocks2.sort((a, b) => {
|
|
18441
|
+
if (a.order_key !== b.order_key) {
|
|
18442
|
+
if (a.order_key === null)
|
|
18443
|
+
return -1;
|
|
18444
|
+
if (b.order_key === null)
|
|
18445
|
+
return 1;
|
|
18446
|
+
return a.order_key < b.order_key ? -1 : 1;
|
|
18447
|
+
}
|
|
18448
|
+
return a.id < b.id ? -1 : 1;
|
|
18449
|
+
});
|
|
18450
|
+
const body = byBlock.size ? serializeDocBlocks(blocks2) : parse5("body") ?? "";
|
|
18451
|
+
return {
|
|
18452
|
+
id,
|
|
18453
|
+
title: parse5("title") ?? "",
|
|
18454
|
+
body,
|
|
18455
|
+
deleted: docRegs.get("__deleted") ? flagSet(docRegs.get("__deleted").value) : false,
|
|
18456
|
+
version: changes[changes.length - 1].hlc
|
|
18457
|
+
};
|
|
18458
|
+
}
|
|
18459
|
+
function revertDocument(db2, id, to, opts = {}) {
|
|
18460
|
+
const cur = getDocument(db2, id);
|
|
18461
|
+
if (!cur)
|
|
18462
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
18463
|
+
if (opts.ifMatch !== undefined && documentVersion(db2, id) !== opts.ifMatch)
|
|
18464
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
18465
|
+
const past = documentAtVersion(db2, id, to);
|
|
18466
|
+
if (past.deleted)
|
|
18467
|
+
throw new MhError("invalid_input", "target version is a deleted state; use doc delete instead");
|
|
18468
|
+
const fields = {};
|
|
18469
|
+
if (past.title !== cur.title)
|
|
18470
|
+
fields.title = past.title;
|
|
18471
|
+
if (past.body !== (cur.body ?? ""))
|
|
18472
|
+
fields.body = past.body;
|
|
18473
|
+
if (Object.keys(fields).length)
|
|
18474
|
+
updateDocument(db2, id, fields);
|
|
18475
|
+
return {
|
|
18476
|
+
id,
|
|
18477
|
+
changed: Object.keys(fields).length > 0,
|
|
18478
|
+
restored: past.version,
|
|
18479
|
+
version: documentVersion(db2, id)
|
|
18480
|
+
};
|
|
18481
|
+
}
|
|
18482
|
+
var RECORD_META2 = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
|
|
18483
|
+
function listRecordRevisions(db2, id) {
|
|
18484
|
+
const changes = rowChanges(db2, "records", id);
|
|
18485
|
+
if (!changes.length)
|
|
18486
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
18487
|
+
return clusterRevisions(changes).map((group) => {
|
|
18488
|
+
const fields = new Set;
|
|
18489
|
+
let created = false;
|
|
18490
|
+
let deleted = false;
|
|
18491
|
+
let moved = false;
|
|
18492
|
+
for (const c of group) {
|
|
18493
|
+
if (c.col === "created_hlc")
|
|
18494
|
+
created = true;
|
|
18495
|
+
else if (c.col === "__deleted")
|
|
18496
|
+
deleted = flagSet(c.value);
|
|
18497
|
+
else if (c.col === "order_key")
|
|
18498
|
+
moved = true;
|
|
18499
|
+
else if (!RECORD_META2.has(c.col))
|
|
18500
|
+
fields.add(c.col);
|
|
18501
|
+
}
|
|
18502
|
+
const last = group[group.length - 1];
|
|
18503
|
+
return {
|
|
18504
|
+
version: last.hlc,
|
|
18505
|
+
at: hlcIso(last.hlc),
|
|
18506
|
+
node_id: last.node_id,
|
|
18507
|
+
changes: group.length,
|
|
18508
|
+
created,
|
|
18509
|
+
deleted,
|
|
18510
|
+
fields: [...fields],
|
|
18511
|
+
moved
|
|
18512
|
+
};
|
|
18513
|
+
}).reverse();
|
|
18514
|
+
}
|
|
18515
|
+
function recordAtVersion(db2, id, at) {
|
|
18516
|
+
if (!at)
|
|
18517
|
+
throw new MhError("invalid_input", "missing version cutoff");
|
|
18518
|
+
const changes = rowChanges(db2, "records", id).filter((c) => c.hlc <= at);
|
|
18519
|
+
if (!changes.length)
|
|
18520
|
+
throw new MhError("not_found", `no version of record ${id} at or before ${at}`);
|
|
18521
|
+
const regs = registersAt(changes, at);
|
|
18522
|
+
const data = {};
|
|
18523
|
+
for (const [col, reg] of regs) {
|
|
18524
|
+
if (RECORD_META2.has(col))
|
|
18525
|
+
continue;
|
|
18526
|
+
if (reg.value === null)
|
|
18527
|
+
continue;
|
|
18528
|
+
data[col] = JSON.parse(reg.value);
|
|
18529
|
+
}
|
|
18530
|
+
const dbReg = regs.get("database_id");
|
|
18531
|
+
return {
|
|
18532
|
+
id,
|
|
18533
|
+
database_id: dbReg?.value == null ? null : JSON.parse(dbReg.value),
|
|
18534
|
+
deleted: regs.get("__deleted") ? flagSet(regs.get("__deleted").value) : false,
|
|
18535
|
+
data,
|
|
18536
|
+
version: changes[changes.length - 1].hlc
|
|
18537
|
+
};
|
|
18538
|
+
}
|
|
18539
|
+
function revertRecord(db2, id, to) {
|
|
18540
|
+
const cur = db2.query("SELECT database_id, data, __deleted FROM records WHERE id = ?").get(id);
|
|
18541
|
+
if (!cur)
|
|
18542
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
18543
|
+
const past = recordAtVersion(db2, id, to);
|
|
18544
|
+
if (past.deleted)
|
|
18545
|
+
throw new MhError("invalid_input", "target version is a deleted state; use record delete instead");
|
|
18546
|
+
const liveProps = new Set(cur.database_id ? listProperties(db2, cur.database_id).map((p) => p.id) : []);
|
|
18547
|
+
const curData = JSON.parse(cur.data || "{}");
|
|
18548
|
+
const fields = [];
|
|
18549
|
+
for (const propId of liveProps) {
|
|
18550
|
+
const inPast = Object.hasOwn(past.data, propId);
|
|
18551
|
+
const inCur = Object.hasOwn(curData, propId);
|
|
18552
|
+
if (inPast && (!inCur || JSON.stringify(past.data[propId]) !== JSON.stringify(curData[propId]))) {
|
|
18553
|
+
emit(db2, "records", id, propId, past.data[propId]);
|
|
18554
|
+
fields.push(propId);
|
|
18555
|
+
} else if (!inPast && inCur) {
|
|
18556
|
+
emit(db2, "records", id, propId, undefined);
|
|
18557
|
+
fields.push(propId);
|
|
18558
|
+
}
|
|
18559
|
+
}
|
|
18560
|
+
const undeleted = cur.__deleted !== 0;
|
|
18561
|
+
if (undeleted)
|
|
18562
|
+
emit(db2, "records", id, "__deleted", 0);
|
|
18563
|
+
return { id, changed: fields.length > 0 || undeleted, fields, undeleted, restored: past.version };
|
|
18564
|
+
}
|
|
18565
|
+
|
|
18566
|
+
// src/webui/server/routes.ts
|
|
18567
|
+
var DatabaseSchema = exports_external.object({
|
|
18568
|
+
id: exports_external.string(),
|
|
18569
|
+
name: exports_external.string(),
|
|
18570
|
+
icon: exports_external.string().nullable(),
|
|
18571
|
+
created_hlc: exports_external.string()
|
|
18572
|
+
});
|
|
18573
|
+
var PropertySchema = exports_external.object({
|
|
18574
|
+
id: exports_external.string(),
|
|
18575
|
+
database_id: exports_external.string(),
|
|
18576
|
+
name: exports_external.string(),
|
|
18577
|
+
type: exports_external.string(),
|
|
18578
|
+
config: exports_external.any().nullable(),
|
|
18579
|
+
position: exports_external.number()
|
|
18580
|
+
});
|
|
18581
|
+
var RecordSchema = exports_external.object({
|
|
18582
|
+
id: exports_external.string(),
|
|
18583
|
+
database_id: exports_external.string(),
|
|
18584
|
+
values: exports_external.record(exports_external.string(), exports_external.any()).describe('Field values keyed by property name (e.g. {"Amount":35}). Reads nest cells here; writes take a flat {column: value} map.')
|
|
18585
|
+
});
|
|
18586
|
+
var MoveRecordReq = exports_external.object({
|
|
18587
|
+
target: exports_external.string(),
|
|
18588
|
+
where: exports_external.enum(["before", "after"])
|
|
18589
|
+
});
|
|
18590
|
+
var DocumentSummarySchema = exports_external.object({
|
|
18591
|
+
id: exports_external.string(),
|
|
18592
|
+
title: exports_external.string(),
|
|
18593
|
+
database_id: exports_external.string().nullable(),
|
|
18594
|
+
parent_id: exports_external.string().nullable(),
|
|
18595
|
+
created_hlc: exports_external.string(),
|
|
18596
|
+
order_key: exports_external.string().nullable()
|
|
18597
|
+
});
|
|
18598
|
+
var MoveDocumentReq = exports_external.object({
|
|
18599
|
+
target: exports_external.string(),
|
|
18600
|
+
where: exports_external.enum(["before", "after", "into"])
|
|
18601
|
+
});
|
|
18602
|
+
var DocumentSchema = DocumentSummarySchema.extend({
|
|
18603
|
+
body: exports_external.string().nullable(),
|
|
18604
|
+
version: exports_external.string().optional()
|
|
18605
|
+
});
|
|
18606
|
+
var SearchHitSchema = exports_external.object({
|
|
18607
|
+
type: exports_external.string(),
|
|
18608
|
+
id: exports_external.string(),
|
|
18609
|
+
database_id: exports_external.string().nullable(),
|
|
18610
|
+
title: exports_external.string().optional(),
|
|
18611
|
+
snippet: exports_external.string()
|
|
18612
|
+
});
|
|
18613
|
+
var OkSchema2 = exports_external.object({ ok: exports_external.boolean() });
|
|
18614
|
+
var CreateDatabaseReq = exports_external.object({ name: exports_external.string(), icon: exports_external.string().optional() });
|
|
18615
|
+
var UpdateDatabaseReq = exports_external.object({
|
|
18616
|
+
name: exports_external.string().optional(),
|
|
18617
|
+
icon: exports_external.string().nullable().optional()
|
|
18618
|
+
});
|
|
18619
|
+
var CreatePropertyReq = exports_external.object({
|
|
18620
|
+
db: exports_external.string(),
|
|
18621
|
+
name: exports_external.string(),
|
|
18622
|
+
type: exports_external.string(),
|
|
18623
|
+
config: exports_external.any().optional()
|
|
18624
|
+
});
|
|
18625
|
+
var UpdatePropertyReq = exports_external.object({
|
|
18626
|
+
name: exports_external.string().optional(),
|
|
18627
|
+
type: exports_external.string().optional(),
|
|
18628
|
+
config: exports_external.any().optional(),
|
|
18629
|
+
position: exports_external.number().optional()
|
|
18630
|
+
});
|
|
18631
|
+
var SetWidthReq = exports_external.object({ width: exports_external.number() });
|
|
18632
|
+
var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any()).describe("Flat { column: value } cell patch (keyed by property name).");
|
|
18633
|
+
var CreateDocumentReq = exports_external.object({
|
|
18634
|
+
title: exports_external.string(),
|
|
18635
|
+
body: exports_external.string().optional(),
|
|
18636
|
+
database_id: exports_external.string().optional(),
|
|
18637
|
+
parent_id: exports_external.string().optional()
|
|
18638
|
+
});
|
|
18639
|
+
var UpdateDocumentReq = exports_external.object({
|
|
18640
|
+
title: exports_external.string().optional(),
|
|
18641
|
+
body: exports_external.string().optional(),
|
|
18642
|
+
parent_id: exports_external.string().nullable().optional(),
|
|
18643
|
+
if_match: exports_external.string().optional()
|
|
18644
|
+
});
|
|
18645
|
+
var RevisionBase = exports_external.object({
|
|
18646
|
+
version: exports_external.string(),
|
|
18647
|
+
at: exports_external.string().describe("Wall-clock time of the revision (ISO 8601)"),
|
|
18648
|
+
node_id: exports_external.string(),
|
|
18649
|
+
changes: exports_external.number(),
|
|
18650
|
+
created: exports_external.boolean(),
|
|
18651
|
+
deleted: exports_external.boolean()
|
|
18652
|
+
});
|
|
18653
|
+
var DocRevisionSchema = RevisionBase.extend({
|
|
18654
|
+
title_changed: exports_external.boolean(),
|
|
18655
|
+
blocks_changed: exports_external.number(),
|
|
18656
|
+
blocks_deleted: exports_external.number()
|
|
18657
|
+
});
|
|
18658
|
+
var RecordRevisionSchema = RevisionBase.extend({
|
|
18659
|
+
fields: exports_external.array(exports_external.string()).describe("Property ids of the cells written"),
|
|
18660
|
+
moved: exports_external.boolean()
|
|
18661
|
+
});
|
|
18662
|
+
var DocumentVersionStateSchema = exports_external.object({
|
|
18663
|
+
id: exports_external.string(),
|
|
18664
|
+
title: exports_external.string(),
|
|
18665
|
+
body: exports_external.string(),
|
|
18666
|
+
deleted: exports_external.boolean(),
|
|
18667
|
+
version: exports_external.string()
|
|
18668
|
+
});
|
|
18669
|
+
var RevertDocumentReq = exports_external.object({
|
|
18670
|
+
to: exports_external.string().describe("Version token from /api/document/history"),
|
|
18671
|
+
if_match: exports_external.string().optional()
|
|
18672
|
+
});
|
|
18673
|
+
var RevertRecordReq = exports_external.object({
|
|
18674
|
+
to: exports_external.string().describe("Version token from /api/record/history")
|
|
18675
|
+
});
|
|
18676
|
+
var RevertDocResultSchema = exports_external.object({
|
|
18677
|
+
id: exports_external.string(),
|
|
18678
|
+
changed: exports_external.boolean(),
|
|
18679
|
+
restored: exports_external.string(),
|
|
18680
|
+
version: exports_external.string()
|
|
18681
|
+
});
|
|
18682
|
+
var RevertRecordResultSchema = exports_external.object({
|
|
18683
|
+
id: exports_external.string(),
|
|
18684
|
+
changed: exports_external.boolean(),
|
|
18685
|
+
fields: exports_external.array(exports_external.string()),
|
|
18686
|
+
undeleted: exports_external.boolean(),
|
|
18687
|
+
restored: exports_external.string()
|
|
18688
|
+
});
|
|
18689
|
+
function need3(req, key) {
|
|
18690
|
+
const v = new URL(req.url).searchParams.get(key);
|
|
18691
|
+
if (!v)
|
|
18692
|
+
throw new Error(`missing query param: ${key}`);
|
|
18693
|
+
return v;
|
|
18694
|
+
}
|
|
18695
|
+
function opt(req, key) {
|
|
18696
|
+
return new URL(req.url).searchParams.get(key) ?? undefined;
|
|
18697
|
+
}
|
|
18698
|
+
function handle3(fn) {
|
|
18699
|
+
return async (req, ctx) => {
|
|
18700
|
+
try {
|
|
18701
|
+
const out = await fn(req, ctx);
|
|
18702
|
+
if (out instanceof Response)
|
|
18703
|
+
return out;
|
|
18704
|
+
return Response.json(out ?? null);
|
|
18705
|
+
} catch (e) {
|
|
18706
|
+
return errorResponse(e);
|
|
18707
|
+
}
|
|
18708
|
+
};
|
|
18709
|
+
}
|
|
18710
|
+
var VersionSchema = exports_external.object({ version: exports_external.string() });
|
|
18711
|
+
var webuiRoutes = [
|
|
18712
|
+
{
|
|
18713
|
+
method: "GET",
|
|
18714
|
+
path: "/api/version",
|
|
18715
|
+
summary: "Version of the running core (sidecar). Used by the desktop app's settings footer.",
|
|
18716
|
+
response: VersionSchema,
|
|
18717
|
+
handler: () => Response.json({ version: package_default.version })
|
|
18718
|
+
},
|
|
18719
|
+
{
|
|
18720
|
+
method: "GET",
|
|
18721
|
+
path: "/api/databases",
|
|
18722
|
+
summary: "List all databases",
|
|
18723
|
+
response: exports_external.array(DatabaseSchema),
|
|
18724
|
+
handler: handle3((_req, { db: db2 }) => listDatabases(db2))
|
|
18725
|
+
},
|
|
18726
|
+
{
|
|
18727
|
+
method: "POST",
|
|
18728
|
+
path: "/api/databases",
|
|
18729
|
+
summary: "Create a database",
|
|
18730
|
+
request: CreateDatabaseReq,
|
|
18731
|
+
response: DatabaseSchema,
|
|
18732
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18733
|
+
const body = await req.json();
|
|
18734
|
+
return createDatabase(db2, body);
|
|
18735
|
+
})
|
|
18736
|
+
},
|
|
18737
|
+
{
|
|
18738
|
+
method: "PATCH",
|
|
18739
|
+
path: "/api/database",
|
|
18740
|
+
summary: "Rename a database or change its icon. Query: ?id=<id>",
|
|
18741
|
+
request: UpdateDatabaseReq,
|
|
18742
|
+
response: DatabaseSchema,
|
|
18743
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18744
|
+
const body = await req.json();
|
|
18745
|
+
return updateDatabase(db2, need3(req, "id"), body);
|
|
18746
|
+
})
|
|
18747
|
+
},
|
|
18748
|
+
{
|
|
18749
|
+
method: "DELETE",
|
|
18750
|
+
path: "/api/database",
|
|
18751
|
+
summary: "Delete a database (and stop it being the current one). Query: ?id=<id>",
|
|
18752
|
+
response: OkSchema2,
|
|
18753
|
+
handler: handle3((req, { db: db2 }) => ({ ok: deleteDatabase(db2, need3(req, "id")) }))
|
|
18754
|
+
},
|
|
18755
|
+
{
|
|
18756
|
+
method: "GET",
|
|
18757
|
+
path: "/api/properties",
|
|
18758
|
+
summary: "List a database's properties (columns). Query: ?db=<id>",
|
|
18759
|
+
response: exports_external.array(PropertySchema),
|
|
18760
|
+
handler: handle3((req, { db: db2 }) => listProperties(db2, need3(req, "db")))
|
|
18761
|
+
},
|
|
18762
|
+
{
|
|
18763
|
+
method: "POST",
|
|
18764
|
+
path: "/api/properties",
|
|
18765
|
+
summary: "Add a property to a database",
|
|
18766
|
+
request: CreatePropertyReq,
|
|
18767
|
+
response: PropertySchema,
|
|
18768
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18769
|
+
const body = await req.json();
|
|
18770
|
+
return addProperty(db2, body.db, { name: body.name, type: body.type, config: body.config });
|
|
18771
|
+
})
|
|
18772
|
+
},
|
|
18773
|
+
{
|
|
18774
|
+
method: "PATCH",
|
|
18775
|
+
path: "/api/property",
|
|
18776
|
+
summary: "Update a property: rename, change type, edit config/options, reorder. Query: ?id=<id>",
|
|
18777
|
+
request: UpdatePropertyReq,
|
|
18778
|
+
response: PropertySchema,
|
|
18779
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18780
|
+
const body = await req.json();
|
|
18781
|
+
return updateProperty(db2, need3(req, "id"), body);
|
|
18782
|
+
})
|
|
18783
|
+
},
|
|
18784
|
+
{
|
|
18785
|
+
method: "PATCH",
|
|
18786
|
+
path: "/api/property/width",
|
|
18787
|
+
summary: "Set a property's table column width (px). Query: ?id=<id>",
|
|
18788
|
+
request: SetWidthReq,
|
|
18789
|
+
response: PropertySchema,
|
|
18790
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18791
|
+
const body = await req.json();
|
|
18792
|
+
return setPropertyWidth(db2, need3(req, "id"), body.width);
|
|
18793
|
+
})
|
|
18794
|
+
},
|
|
18795
|
+
{
|
|
18796
|
+
method: "DELETE",
|
|
18797
|
+
path: "/api/property",
|
|
18798
|
+
summary: "Delete a property (column) and its cells. Query: ?id=<id>",
|
|
18799
|
+
response: OkSchema2,
|
|
18800
|
+
handler: handle3((req, { db: db2 }) => ({ ok: removeProperty(db2, need3(req, "id")) }))
|
|
18801
|
+
},
|
|
18802
|
+
{
|
|
18803
|
+
method: "GET",
|
|
18804
|
+
path: "/api/records",
|
|
18805
|
+
summary: "List records in a database. Query: ?db=<id>&sort=<field>&limit=<n>",
|
|
18806
|
+
response: exports_external.array(RecordSchema),
|
|
18807
|
+
handler: handle3((req, { db: db2 }) => {
|
|
18808
|
+
const limit = opt(req, "limit");
|
|
18809
|
+
return listRecords(db2, need3(req, "db"), {
|
|
18810
|
+
sort: opt(req, "sort"),
|
|
18811
|
+
limit: limit ? Number(limit) : undefined
|
|
18812
|
+
});
|
|
18813
|
+
})
|
|
18814
|
+
},
|
|
18815
|
+
{
|
|
18816
|
+
method: "POST",
|
|
18817
|
+
path: "/api/records",
|
|
18818
|
+
summary: "Create a record. Query: ?db=<id>; body is a {field: value} map",
|
|
18819
|
+
request: RecordValuesReq,
|
|
18820
|
+
response: RecordSchema,
|
|
18821
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18822
|
+
const values = await req.json();
|
|
18823
|
+
return createRecord(db2, need3(req, "db"), values);
|
|
18824
|
+
})
|
|
18825
|
+
},
|
|
18826
|
+
{
|
|
18827
|
+
method: "GET",
|
|
18828
|
+
path: "/api/record",
|
|
18829
|
+
summary: "Get one record. Query: ?id=<id>",
|
|
18830
|
+
response: RecordSchema,
|
|
18831
|
+
handler: handle3((req, { db: db2 }) => {
|
|
18832
|
+
const id = need3(req, "id");
|
|
18833
|
+
const rec = getRecord(db2, id);
|
|
18834
|
+
if (!rec)
|
|
18835
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
18836
|
+
return rec;
|
|
18837
|
+
})
|
|
18838
|
+
},
|
|
18839
|
+
{
|
|
18840
|
+
method: "PATCH",
|
|
18841
|
+
path: "/api/record",
|
|
18842
|
+
summary: "Update record fields. Query: ?id=<id>; body is a {field: value} map",
|
|
18843
|
+
request: RecordValuesReq,
|
|
18844
|
+
response: RecordSchema,
|
|
18845
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18846
|
+
const values = await req.json();
|
|
18847
|
+
return updateRecord(db2, need3(req, "id"), values);
|
|
18848
|
+
})
|
|
18849
|
+
},
|
|
18850
|
+
{
|
|
18851
|
+
method: "PATCH",
|
|
18852
|
+
path: "/api/record/order",
|
|
18853
|
+
summary: "Move a record before or after another record. Query: ?id=<id>",
|
|
18854
|
+
request: MoveRecordReq,
|
|
18855
|
+
response: RecordSchema,
|
|
18856
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18857
|
+
const body = await req.json();
|
|
18858
|
+
return moveRecord(db2, need3(req, "id"), body.target, body.where);
|
|
18859
|
+
})
|
|
18860
|
+
},
|
|
18861
|
+
{
|
|
18862
|
+
method: "GET",
|
|
18863
|
+
path: "/api/record/history",
|
|
18864
|
+
summary: "List a record's revisions, newest first. Query: ?id=<id>",
|
|
18865
|
+
response: exports_external.array(RecordRevisionSchema),
|
|
18866
|
+
handler: handle3((req, { db: db2 }) => listRecordRevisions(db2, need3(req, "id")))
|
|
18867
|
+
},
|
|
18868
|
+
{
|
|
18869
|
+
method: "POST",
|
|
18870
|
+
path: "/api/record/revert",
|
|
18871
|
+
summary: "Restore a record's cells to a past version, recorded as a new revision. Query: ?id=<id>",
|
|
18872
|
+
request: RevertRecordReq,
|
|
18873
|
+
response: RevertRecordResultSchema,
|
|
18874
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18875
|
+
const body = await req.json();
|
|
18876
|
+
return revertRecord(db2, need3(req, "id"), body.to);
|
|
18877
|
+
})
|
|
18878
|
+
},
|
|
18879
|
+
{
|
|
18880
|
+
method: "DELETE",
|
|
18881
|
+
path: "/api/record",
|
|
18882
|
+
summary: "Delete a record. Query: ?id=<id>",
|
|
18883
|
+
response: OkSchema2,
|
|
18884
|
+
handler: handle3((req, { db: db2 }) => ({ ok: deleteRecord(db2, need3(req, "id")) }))
|
|
18885
|
+
},
|
|
18886
|
+
{
|
|
18887
|
+
method: "GET",
|
|
18888
|
+
path: "/api/documents",
|
|
18889
|
+
summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
|
|
18890
|
+
response: exports_external.array(DocumentSummarySchema),
|
|
18891
|
+
handler: handle3((req, { db: db2 }) => listDocuments(db2, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
|
|
18892
|
+
},
|
|
18893
|
+
{
|
|
18894
|
+
method: "POST",
|
|
18895
|
+
path: "/api/documents",
|
|
18896
|
+
summary: "Create a document",
|
|
18897
|
+
request: CreateDocumentReq,
|
|
18898
|
+
response: DocumentSchema,
|
|
18899
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18900
|
+
const body = await req.json();
|
|
18901
|
+
return createDocument(db2, body);
|
|
18902
|
+
})
|
|
18903
|
+
},
|
|
18904
|
+
{
|
|
18905
|
+
method: "GET",
|
|
18906
|
+
path: "/api/document",
|
|
18907
|
+
summary: "Get one document with full body. Query: ?id=<id>",
|
|
18908
|
+
response: DocumentSchema,
|
|
18909
|
+
handler: handle3((req, { db: db2 }) => {
|
|
18910
|
+
const id = need3(req, "id");
|
|
18911
|
+
const doc2 = getDocument(db2, id);
|
|
18912
|
+
if (!doc2)
|
|
18913
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
18914
|
+
return { ...doc2, version: documentVersion(db2, doc2.id) };
|
|
18915
|
+
})
|
|
18916
|
+
},
|
|
18917
|
+
{
|
|
18918
|
+
method: "PATCH",
|
|
18919
|
+
path: "/api/document",
|
|
18920
|
+
summary: "Update a document's title/body/parent_id (null = top level). Query: ?id=<id>",
|
|
18921
|
+
request: UpdateDocumentReq,
|
|
18922
|
+
response: DocumentSchema,
|
|
18923
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18924
|
+
const body = await req.json();
|
|
18925
|
+
const { if_match, ...fields } = body;
|
|
18926
|
+
const doc2 = updateDocument(db2, need3(req, "id"), fields, { ifMatch: if_match });
|
|
18927
|
+
return { ...doc2, version: documentVersion(db2, doc2.id) };
|
|
18928
|
+
})
|
|
18929
|
+
},
|
|
18930
|
+
{
|
|
18931
|
+
method: "PATCH",
|
|
18932
|
+
path: "/api/document/move",
|
|
18933
|
+
summary: "Move a document before/after another, or into it as a child. Query: ?id=<id>",
|
|
18934
|
+
request: MoveDocumentReq,
|
|
18935
|
+
response: DocumentSchema,
|
|
18936
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18937
|
+
const body = await req.json();
|
|
18938
|
+
return moveDocument(db2, need3(req, "id"), body.target, body.where);
|
|
18939
|
+
})
|
|
18940
|
+
},
|
|
18941
|
+
{
|
|
18942
|
+
method: "GET",
|
|
18943
|
+
path: "/api/document/history",
|
|
18944
|
+
summary: "List a document's revisions, newest first. Query: ?id=<id>",
|
|
18945
|
+
response: exports_external.array(DocRevisionSchema),
|
|
18946
|
+
handler: handle3((req, { db: db2 }) => listDocumentRevisions(db2, need3(req, "id")))
|
|
18947
|
+
},
|
|
18948
|
+
{
|
|
18949
|
+
method: "GET",
|
|
18950
|
+
path: "/api/document/at",
|
|
18951
|
+
summary: "A document's title/body as of a past version. Query: ?id=<id>&version=<token>",
|
|
18952
|
+
response: DocumentVersionStateSchema,
|
|
18953
|
+
handler: handle3((req, { db: db2 }) => documentAtVersion(db2, need3(req, "id"), need3(req, "version")))
|
|
18954
|
+
},
|
|
18955
|
+
{
|
|
18956
|
+
method: "POST",
|
|
18957
|
+
path: "/api/document/revert",
|
|
18958
|
+
summary: "Restore a document's title/body to a past version, recorded as a new revision. Query: ?id=<id>",
|
|
18959
|
+
request: RevertDocumentReq,
|
|
18960
|
+
response: RevertDocResultSchema,
|
|
18961
|
+
handler: handle3(async (req, { db: db2 }) => {
|
|
18962
|
+
const body = await req.json();
|
|
18963
|
+
return revertDocument(db2, need3(req, "id"), body.to, { ifMatch: body.if_match });
|
|
18964
|
+
})
|
|
18965
|
+
},
|
|
18966
|
+
{
|
|
18967
|
+
method: "DELETE",
|
|
18968
|
+
path: "/api/document",
|
|
18969
|
+
summary: "Delete a document. Query: ?id=<id>",
|
|
18970
|
+
response: OkSchema2,
|
|
18971
|
+
handler: handle3((req, { db: db2 }) => ({ ok: deleteDocument(db2, need3(req, "id")) }))
|
|
18972
|
+
},
|
|
18973
|
+
{
|
|
18974
|
+
method: "GET",
|
|
18975
|
+
path: "/api/search",
|
|
18976
|
+
summary: "Full-text search across documents and records. Query: ?q=<text>&limit=<n>",
|
|
18977
|
+
response: exports_external.array(SearchHitSchema),
|
|
18978
|
+
handler: handle3((req, { db: db2 }) => {
|
|
18979
|
+
const limit = opt(req, "limit");
|
|
18980
|
+
return search(db2, need3(req, "q"), { limit: limit ? Number(limit) : undefined });
|
|
18981
|
+
})
|
|
18982
|
+
}
|
|
18983
|
+
];
|
|
18023
18984
|
export {
|
|
18985
|
+
webuiRoutes,
|
|
18986
|
+
warmWebui,
|
|
18987
|
+
updateSite,
|
|
18024
18988
|
updateRecord,
|
|
18025
18989
|
updateProperty,
|
|
18026
18990
|
updateDocument,
|
|
@@ -18028,8 +18992,10 @@ export {
|
|
|
18028
18992
|
syncWithPeer,
|
|
18029
18993
|
startServer,
|
|
18030
18994
|
slugify,
|
|
18995
|
+
setWebuiBundle,
|
|
18031
18996
|
setPropertyWidth,
|
|
18032
18997
|
setCurrentDatabase,
|
|
18998
|
+
serveWebui,
|
|
18033
18999
|
serializeDocBlocks,
|
|
18034
19000
|
search,
|
|
18035
19001
|
runSchema,
|
|
@@ -18048,11 +19014,15 @@ export {
|
|
|
18048
19014
|
parseDocBlocks,
|
|
18049
19015
|
openMetahub,
|
|
18050
19016
|
observeHlc,
|
|
19017
|
+
normalizeSitePath,
|
|
19018
|
+
normalizeSiteName,
|
|
18051
19019
|
nextHlc,
|
|
18052
19020
|
newId,
|
|
18053
19021
|
moveRecord,
|
|
19022
|
+
moveDocument,
|
|
18054
19023
|
migrateRecords,
|
|
18055
19024
|
migratePeers,
|
|
19025
|
+
migrateDocuments,
|
|
18056
19026
|
migrateDocBlocks,
|
|
18057
19027
|
metahubHome,
|
|
18058
19028
|
maybeAutoIndex,
|
|
@@ -18081,6 +19051,7 @@ export {
|
|
|
18081
19051
|
getBlob,
|
|
18082
19052
|
ftsAvailable,
|
|
18083
19053
|
formatHlc,
|
|
19054
|
+
errorCode,
|
|
18084
19055
|
ensurePropIndex,
|
|
18085
19056
|
ensureDirs,
|
|
18086
19057
|
emitFields,
|
|
@@ -18103,6 +19074,7 @@ export {
|
|
|
18103
19074
|
cacheDir,
|
|
18104
19075
|
blobPath,
|
|
18105
19076
|
backfillRecordOrderKeys,
|
|
19077
|
+
backfillDocumentOrderKeys,
|
|
18106
19078
|
applyChange,
|
|
18107
19079
|
appendDocument,
|
|
18108
19080
|
addProperty,
|
|
@@ -18115,9 +19087,10 @@ export {
|
|
|
18115
19087
|
PairRequestSchema,
|
|
18116
19088
|
PROP_TYPES,
|
|
18117
19089
|
PAIR_PATH,
|
|
19090
|
+
MhError,
|
|
18118
19091
|
HealthResponseSchema,
|
|
18119
19092
|
HEALTH_PATH,
|
|
18120
19093
|
ChangeSchema
|
|
18121
19094
|
};
|
|
18122
19095
|
|
|
18123
|
-
//# debugId=
|
|
19096
|
+
//# debugId=3270CE1250D22E6A64756E2164756E21
|