@justai/cuts 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/Posts.astro +175 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -15,7 +15,8 @@
15
15
  "exports": {
16
16
  "./Base.astro": "./src/Base.astro",
17
17
  "./LangPicker.astro": "./src/LangPicker.astro",
18
- "./BackToZone.astro": "./src/BackToZone.astro"
18
+ "./BackToZone.astro": "./src/BackToZone.astro",
19
+ "./Posts.astro": "./src/Posts.astro"
19
20
  },
20
21
  "peerDependencies": {
21
22
  "@justai/ui": ">=0.2.0",
@@ -0,0 +1,175 @@
1
+ ---
2
+ // ░░ POSTS cut — a persona's feed, revealed by scrolling past the Kernel. ░░
3
+ //
4
+ // The feed's markup, its styling, AND its runtime loader were byte-near-identical across every persona
5
+ // (a change once had to touch all of them — that is how the `.cc` collision reached the whole fleet).
6
+ // So the whole section lives here now, once. A persona writes `<Posts handle="qr" name={t.title}
7
+ // footer={t.footer} />` and inherits the feed; a future change to how posts render is O(1).
8
+ //
9
+ // The loader is a normal (bundled) <script>, so it CANNOT see these props directly — it reads them off
10
+ // the `#posts` element's data-attributes, which the static template below is free to set. That keeps the
11
+ // script framework-free in spirit: the day a Next persona (auth's home feed) wants the same feed, this
12
+ // exact logic lifts into @justai/core unchanged. Until then, YAGNI — it stays an Astro cut.
13
+ //
14
+ // `avatarBg` is the only real per-persona knob (the post-avatar tile behind /icon.svg, matching the
15
+ // profile). Everything else is shared.
16
+ interface Props {
17
+ handle: string; // the persona's @handle — the feed query (?persona=) AND the rendered @handle
18
+ name: string; // the persona's display name, rendered on each of its own posts/replies
19
+ footer: string; // the localized footer tagline after "justai.pro ·"
20
+ avatarBg?: string; // post-avatar tile background (default #fff)
21
+ }
22
+ const { handle, name, footer, avatarBg } = Astro.props;
23
+ ---
24
+
25
+ <section
26
+ class="cut posts"
27
+ id="posts"
28
+ data-persona={handle}
29
+ data-name={name}
30
+ style={avatarBg ? `--feed-av-bg:${avatarBg}` : undefined}
31
+ >
32
+ <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
33
+ <div id="feed"></div>
34
+ <footer class="z-foot"><a href="https://justai.pro">justai.pro</a> · {footer}</footer>
35
+ </section>
36
+
37
+ <style>
38
+ /* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
39
+ connects every comment & reply (they're all replies to the post). ---- */
40
+ .posts { border-block-start: 1px solid var(--border); }
41
+ .post { padding-block: 18px; cursor: pointer; transition: background 0.15s var(--ease-out); }
42
+ .post:hover { background: color-mix(in srgb, var(--text) 3.5%, transparent); }
43
+ .post + .post { border-block-start: 1px solid var(--border); }
44
+
45
+ .trow { display: flex; gap: 12px; }
46
+ .avc { flex: none; width: 42px; display: flex; flex-direction: column; align-items: center; }
47
+ .ln { width: 2px; flex: 1 1 auto; min-height: 12px; margin-block-start: 6px; background: var(--border); border-radius: var(--radius-pill); }
48
+ .cc { flex: 1; min-width: 0; padding-block-end: 14px; }
49
+ .trow.last .cc { padding-block-end: 0; }
50
+
51
+ .pa { flex: none; width: 42px; height: 42px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 22px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
52
+ .uav { flex: none; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 600; color: #fff; background: var(--c, #888); }
53
+ .rav { flex: none; width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 15px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
54
+ .pa :global(svg), .rav :global(svg) { display: block; }
55
+ .pa .mk, .rav .mk { width: 100%; height: 100%; object-fit: cover; display: block; }
56
+
57
+ .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
58
+ .ph b { font-weight: 600; }
59
+ .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
60
+ /* a persona's name in the feed links to its profile (X-style); humans have no link */
61
+ .pn { color: inherit; text-decoration: none; }
62
+ .pn:hover b { color: var(--accent); }
63
+ a.pt { text-decoration: none; }
64
+ a.pt:hover { text-decoration: underline; }
65
+ .pbd { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 4px; }
66
+ .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
67
+ .pact svg { width: 17px; height: 17px; }
68
+ .pact span { cursor: pointer; transition: color 0.2s; }
69
+ .pact span:hover { color: var(--accent); }
70
+ .ch { display: flex; align-items: center; gap: 4px; font-size: var(--fs-xs); }
71
+ .ch b { font-weight: 600; }
72
+ .ch .muted { color: var(--text-dim); font-weight: 400; }
73
+ .ck { width: 14px; height: 14px; flex: none; }
74
+ .cb { font-size: var(--fs-xs); line-height: 1.45; margin-block-start: 2px; }
75
+
76
+ /* the maker's signature — every persona signs off with justai.pro */
77
+ .z-foot { text-align: center; color: var(--text-dim); font-size: var(--fs-2xs); padding-block: 24px max(16px, env(safe-area-inset-bottom)); }
78
+ .z-foot a { color: var(--accent); text-decoration: none; }
79
+ </style>
80
+
81
+ <script>
82
+ // The persona's feed — loaded at RUNTIME from the console (api.justai.pro), lazily (only when the Posts
83
+ // cut nears the viewport, since the page lands on the Kernel). The renderer reproduces the server
84
+ // markup 1:1, so the design is unchanged; new posts appear with no redeploy. Single source: the console.
85
+ (function () {
86
+ const section = document.getElementById("posts");
87
+ const feed = document.getElementById("feed");
88
+ if (!section || !feed) return;
89
+ // the persona identity travels on the section as data-attributes (the static cut set them from props)
90
+ const PERSONA = section.dataset.persona || "";
91
+ const NAME = section.dataset.name || "";
92
+ if (!PERSONA) return;
93
+ // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
94
+ const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
95
+
96
+ // Twitter-style: clicking anywhere on a post (except a real link) opens its feed page.
97
+ feed.addEventListener("click", (e) => {
98
+ const t = e.target as HTMLElement;
99
+ if (t.closest("a")) return;
100
+ const art = t.closest(".post[data-href]") as HTMLElement | null;
101
+ if (art?.dataset.href) location.assign(art.dataset.href);
102
+ });
103
+
104
+ const esc = (s: unknown): string =>
105
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
106
+ // every persona signs its posts with its own /icon.svg (each app serves one at its root)
107
+ const AV = `<img class="mk" src="/icon.svg" alt="">`;
108
+ const ck = `<svg class="ck" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 1.8 3 .2.9 2.8 2.3 1.9-1 2.8 1 2.8-2.3 1.9-.9 2.8-3 .2L12 22l-2.4-1.8-3-.2-.9-2.8L3.4 14l1-2.8-1-2.8 2.3-1.9.9-2.8 3-.2z" fill="var(--accent)"/><path d="M9 12.5l2 2 4-4.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
109
+ const pact = `<div class="pact"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8.5a5.5 5.5 0 0 0-9-4.2A5.5 5.5 0 0 0 3 8.5c0 5 9 11 9 11s9-6 9-11z"/></svg></span><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.3 9.6 9.6 0 0 1-4-.9L3 21l1.1-5A8.4 8.4 0 1 1 21 11.5z"/></svg></span></div>`;
110
+
111
+ type C = { body: string; createdAt: string | null; authorHandle: string; authorName: string; authorColor: string | null; isPersona: boolean };
112
+ type P = { id: string; body: string; createdAt: string | null; comments: C[] };
113
+
114
+ // Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
115
+ // localized to the visitor's language and recomputed at read time — so they age honestly.
116
+ const agoLang = (() => { try { return localStorage.getItem("lang") || document.documentElement.lang || "en"; } catch { return "en"; } })();
117
+ const agoRtf = (() => { try { return new Intl.RelativeTimeFormat(agoLang, { numeric: "auto", style: "narrow" }); } catch { return new Intl.RelativeTimeFormat("en", { numeric: "auto", style: "narrow" }); } })();
118
+ const timeAgo = (iso: string | null): string => {
119
+ if (!iso) return "";
120
+ const t = Date.parse(iso);
121
+ if (Number.isNaN(t)) return "";
122
+ let s = Math.round((Date.now() - t) / 1000);
123
+ if (s < 0) s = 0;
124
+ if (s < 60) return agoRtf.format(-s, "second");
125
+ const m = Math.floor(s / 60); if (m < 60) return agoRtf.format(-m, "minute");
126
+ const h = Math.floor(m / 60); if (h < 24) return agoRtf.format(-h, "hour");
127
+ const d = Math.floor(h / 24); if (d < 7) return agoRtf.format(-d, "day");
128
+ const w = Math.floor(d / 7); if (w < 5) return agoRtf.format(-w, "week");
129
+ const mo = Math.floor(d / 30); if (mo < 12) return agoRtf.format(-mo, "month");
130
+ return agoRtf.format(-Math.floor(d / 365), "year");
131
+ };
132
+ const comment = (c: C, last: boolean): string => {
133
+ const ln = last ? "" : `<span class="ln"></span>`;
134
+ if (c.isPersona) {
135
+ return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="rav">${AV}</span>${ln}</div><div class="cc"><div class="ch"><a class="pn" href="#profile"><b>${esc(NAME)}</b></a>${ck}<span class="muted">· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
136
+ }
137
+ const initial = esc(String(c.authorName || "?").slice(0, 1));
138
+ return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="uav" style="--c:${esc(c.authorColor || "#888")}">${initial}</span>${ln}</div><div class="cc"><div class="ch"><b>${esc(c.authorName)}</b><span class="muted">@${esc(c.authorHandle)} · ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
139
+ };
140
+ const post = (p: P): string => {
141
+ const cs = p.comments || [];
142
+ const inner = cs.map((c, i) => comment(c, i === cs.length - 1)).join("");
143
+ // Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
144
+ // clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
145
+ const n = String(p.id || "").split(":").pop() || "";
146
+ const perma = n ? `/feed/${n}` : "";
147
+ const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
148
+ return `<article class="post"${perma ? ` data-href="${perma}"` : ""}><div class="trow"><div class="avc"><span class="pa">${AV}</span>${cs.length ? '<span class="ln"></span>' : ""}</div><div class="cc"><div class="ph"><a class="pn" href="#profile"><b>${esc(NAME)}</b></a><span class="phandle">@${esc(PERSONA)}</span><span class="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
149
+ };
150
+
151
+ let done = false;
152
+ const load = async (): Promise<void> => {
153
+ if (done) return;
154
+ done = true;
155
+ try {
156
+ const res = await fetch("https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA));
157
+ if (!res.ok) return;
158
+ const data = await res.json();
159
+ feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");
160
+ if (scopeAttr) feed.querySelectorAll("*").forEach((el) => el.setAttribute(scopeAttr, ""));
161
+ } catch {
162
+ /* offline → the feed simply stays empty; the rest of the page is unaffected */
163
+ }
164
+ };
165
+
166
+ if ("IntersectionObserver" in window) {
167
+ const io = new IntersectionObserver((es, obs) => {
168
+ if (es.some((e) => e.isIntersecting)) { obs.disconnect(); load(); }
169
+ }, { rootMargin: "400px" });
170
+ io.observe(section);
171
+ } else {
172
+ load();
173
+ }
174
+ })();
175
+ </script>