@pylonsync/create-pylon 0.11.0 → 0.11.3
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/bin/create-pylon.js +1 -1
- package/package.json +1 -1
- package/templates/_root/AGENTS.md +31 -1
- package/templates/agency/AGENTS.md +1 -1
- package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
- package/templates/agency/app/(marketing)/layout.tsx +36 -64
- package/templates/agency/app/(marketing)/not-found.tsx +6 -8
- package/templates/agency/app/(marketing)/page.tsx +93 -144
- package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
- package/templates/agency/app/(marketing)/work/page.tsx +20 -17
- package/templates/agency/app/globals.css +15 -6
- package/templates/agency/app.ts +10 -0
- package/templates/agency/components/marketing.tsx +40 -104
- package/templates/agency/lib/site.config.ts +34 -52
- package/templates/agency/public/images/hero.jpg +0 -0
- package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
- package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
- package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
- package/templates/agency/public/images/work/atlas-health.jpg +0 -0
- package/templates/agency/public/images/work/cohort.jpg +0 -0
- package/templates/agency/public/images/work/ledger.jpg +0 -0
- package/templates/agency/public/images/work/vela.jpg +0 -0
- package/templates/ai-chat/AGENTS.md +1 -1
- package/templates/ai-studio/AGENTS.md +1 -1
- package/templates/barebones/AGENTS.md +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/consumer/AGENTS.md +1 -1
- package/templates/consumer/README.md +39 -23
- package/templates/consumer/app/(app)/explore/page.tsx +13 -0
- package/templates/consumer/app/(app)/layout.tsx +17 -0
- package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
- package/templates/consumer/app/(app)/page.tsx +15 -0
- package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
- package/templates/consumer/app/error.tsx +1 -1
- package/templates/consumer/app/globals.css +25 -0
- package/templates/consumer/app/layout.tsx +3 -10
- package/templates/consumer/app/llms.ts +10 -10
- package/templates/consumer/app/not-found.tsx +9 -22
- package/templates/consumer/app/robots.ts +3 -4
- package/templates/consumer/app/sitemap.ts +7 -21
- package/templates/consumer/app.ts +152 -33
- package/templates/consumer/components/social/app-chrome.tsx +109 -0
- package/templates/consumer/components/social/avatar.tsx +50 -0
- package/templates/consumer/components/social/comment-form.tsx +51 -0
- package/templates/consumer/components/social/create-post.tsx +155 -0
- package/templates/consumer/components/social/edit-profile.tsx +103 -0
- package/templates/consumer/components/social/explore-screen.tsx +23 -0
- package/templates/consumer/components/social/home-screen.tsx +161 -0
- package/templates/consumer/components/social/post-card.tsx +77 -0
- package/templates/consumer/components/social/post-grid.tsx +46 -0
- package/templates/consumer/components/social/post-parts.tsx +146 -0
- package/templates/consumer/components/social/post-screen.tsx +173 -0
- package/templates/consumer/components/social/profile-screen.tsx +146 -0
- package/templates/consumer/components/social/rich-text.tsx +22 -0
- package/templates/consumer/components/social/session.tsx +43 -0
- package/templates/consumer/components/social/use-social.ts +86 -0
- package/templates/consumer/functions/addComment.ts +27 -0
- package/templates/consumer/functions/createPost.ts +39 -0
- package/templates/consumer/functions/ensureProfile.ts +39 -0
- package/templates/consumer/functions/seedFeed.ts +38 -0
- package/templates/consumer/functions/updateProfile.ts +61 -0
- package/templates/consumer/lib/seed.ts +192 -0
- package/templates/consumer/lib/site.ts +6 -0
- package/templates/consumer/lib/social.ts +245 -0
- package/templates/consumer/tests/social.test.ts +157 -0
- package/templates/creator/AGENTS.md +1 -1
- package/templates/creator/app/(marketing)/layout.tsx +35 -69
- package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
- package/templates/creator/app/(marketing)/not-found.tsx +8 -6
- package/templates/creator/app/(marketing)/page.tsx +90 -106
- package/templates/creator/app/globals.css +14 -0
- package/templates/creator/app/layout.tsx +1 -0
- package/templates/creator/app.ts +10 -0
- package/templates/creator/components/marketing.tsx +28 -89
- package/templates/creator/lib/site.config.ts +51 -29
- package/templates/creator/public/images/headshot.jpg +0 -0
- package/templates/crm/AGENTS.md +1 -1
- package/templates/crm/lib/seed.ts +153 -21
- package/templates/directory/AGENTS.md +1 -1
- package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
- package/templates/directory/app/(marketing)/layout.tsx +37 -88
- package/templates/directory/app/(marketing)/not-found.tsx +11 -10
- package/templates/directory/app/(marketing)/page.tsx +12 -33
- package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
- package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
- package/templates/directory/app/globals.css +15 -4
- package/templates/directory/app.ts +10 -0
- package/templates/directory/components/marketing.tsx +27 -91
- package/templates/directory/lib/site.config.ts +13 -24
- package/templates/expo/mobile/apps/expo/README.md +1 -1
- package/templates/expo/mobile/apps/expo/package.json +55 -54
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +24 -6
- package/templates/helpdesk/AGENTS.md +1 -1
- package/templates/helpdesk/lib/seed.ts +188 -104
- package/templates/inventory/AGENTS.md +1 -1
- package/templates/inventory/lib/seed.ts +175 -44
- package/templates/invoices/AGENTS.md +1 -1
- package/templates/invoices/lib/seed.ts +74 -89
- package/templates/local-service/AGENTS.md +1 -1
- package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
- package/templates/local-service/app/(marketing)/layout.tsx +25 -37
- package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
- package/templates/local-service/app/(marketing)/page.tsx +145 -139
- package/templates/local-service/app/globals.css +11 -0
- package/templates/local-service/app/layout.tsx +2 -0
- package/templates/local-service/app.ts +9 -0
- package/templates/local-service/components/marketing.tsx +14 -39
- package/templates/local-service/lib/site.config.ts +16 -23
- package/templates/local-service/public/images/hero.jpg +0 -0
- package/templates/marketplace/AGENTS.md +1 -1
- package/templates/marketplace/client/SellForm.tsx +2 -0
- package/templates/projects/AGENTS.md +1 -1
- package/templates/projects/lib/seed.ts +103 -32
- package/templates/restaurant/AGENTS.md +1 -1
- package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
- package/templates/restaurant/app/(marketing)/page.tsx +146 -123
- package/templates/restaurant/app/globals.css +11 -0
- package/templates/restaurant/app/layout.tsx +1 -0
- package/templates/restaurant/app.ts +10 -0
- package/templates/restaurant/components/marketing.tsx +10 -0
- package/templates/restaurant/lib/site.config.ts +16 -14
- package/templates/restaurant/public/images/bar.jpg +0 -0
- package/templates/restaurant/public/images/branzino.jpg +0 -0
- package/templates/restaurant/public/images/hearth.jpg +0 -0
- package/templates/restaurant/public/images/hero.jpg +0 -0
- package/templates/saas/AGENTS.md +1 -1
- package/templates/shop/AGENTS.md +1 -1
- package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
- package/templates/shop/public/images/products/linen.jpg +0 -0
- package/templates/todo/AGENTS.md +1 -1
- package/templates/waitlist/AGENTS.md +1 -1
- package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
- package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
- package/templates/waitlist/app/(marketing)/page.tsx +51 -86
- package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
- package/templates/waitlist/app/globals.css +23 -0
- package/templates/waitlist/app/layout.tsx +1 -0
- package/templates/waitlist/app.ts +18 -0
- package/templates/waitlist/components/marketing.tsx +67 -81
- package/templates/waitlist/lib/site.config.ts +52 -59
- package/templates/agency/app/not-found.tsx +0 -44
- package/templates/ai-chat/app/not-found.tsx +0 -44
- package/templates/ai-studio/app/not-found.tsx +0 -44
- package/templates/consumer/app/feed-client.tsx +0 -159
- package/templates/consumer/app/page.tsx +0 -27
- package/templates/consumer/functions/_keep.ts +0 -13
- package/templates/consumer/functions/seedPosts.ts +0 -56
- package/templates/consumer/tests/example.test.ts +0 -12
- package/templates/creator/app/not-found.tsx +0 -44
- package/templates/directory/app/not-found.tsx +0 -44
- package/templates/local-service/app/not-found.tsx +0 -44
- package/templates/restaurant/app/not-found.tsx +0 -44
- package/templates/shop/app/not-found.tsx +0 -44
- package/templates/waitlist/app/not-found.tsx +0 -44
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The social model: usernames, counts, time labels, and which posts a
|
|
2
|
+
// person sees. Pure — no React, no `db` — so the rules are testable without a
|
|
3
|
+
// server, and the components stay thin.
|
|
4
|
+
|
|
5
|
+
export interface Profile {
|
|
6
|
+
id: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
displayName: string;
|
|
10
|
+
bio?: string | null;
|
|
11
|
+
avatarUrl?: string | null;
|
|
12
|
+
createdAt?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Post {
|
|
16
|
+
id: string;
|
|
17
|
+
authorId: string;
|
|
18
|
+
imageUrl: string;
|
|
19
|
+
caption?: string | null;
|
|
20
|
+
/** "square" (1:1) or "portrait" (4:5). The feed crops to this shape. */
|
|
21
|
+
shape?: string | null;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Like {
|
|
26
|
+
id: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
postId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Comment {
|
|
32
|
+
id: string;
|
|
33
|
+
postId: string;
|
|
34
|
+
authorId: string;
|
|
35
|
+
text: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Follow {
|
|
40
|
+
id: string;
|
|
41
|
+
followerId: string;
|
|
42
|
+
followingId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Save {
|
|
46
|
+
id: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
postId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Usernames
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const USERNAME_MIN = 3;
|
|
56
|
+
export const USERNAME_MAX = 30;
|
|
57
|
+
export const CAPTION_MAX = 2200;
|
|
58
|
+
export const COMMENT_MAX = 500;
|
|
59
|
+
export const BIO_MAX = 150;
|
|
60
|
+
export const DISPLAY_NAME_MAX = 40;
|
|
61
|
+
|
|
62
|
+
/** Lowercase, trimmed, a leading @ dropped. Does not validate. */
|
|
63
|
+
export function normalizeUsername(raw: string): string {
|
|
64
|
+
return raw.trim().replace(/^@+/, "").toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Why a username is refused, or null when it is fine: 3 to 30 characters of
|
|
69
|
+
* letters, digits, periods, and underscores, not starting or ending with a
|
|
70
|
+
* period, and no two periods in a row.
|
|
71
|
+
*/
|
|
72
|
+
export function usernameProblem(username: string): string | null {
|
|
73
|
+
if (username.length < USERNAME_MIN) return `Use at least ${USERNAME_MIN} characters.`;
|
|
74
|
+
if (username.length > USERNAME_MAX) return `Use at most ${USERNAME_MAX} characters.`;
|
|
75
|
+
if (!/^[a-z0-9._]+$/.test(username)) return "Use letters, numbers, periods, and underscores only.";
|
|
76
|
+
if (username.startsWith(".") || username.endsWith(".")) return "A username cannot start or end with a period.";
|
|
77
|
+
if (username.includes("..")) return "A username cannot have two periods in a row.";
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A stable starting username for a new account, from its user id. */
|
|
82
|
+
export function starterUsername(userId: string): string {
|
|
83
|
+
let hash = 2166136261;
|
|
84
|
+
for (let i = 0; i < userId.length; i++) {
|
|
85
|
+
hash ^= userId.charCodeAt(i);
|
|
86
|
+
hash = Math.imul(hash, 16777619);
|
|
87
|
+
}
|
|
88
|
+
return `visitor_${(hash >>> 0).toString(36).slice(0, 6)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Labels
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** 999 → "999", 1204 → "1,204", 12_500 → "12.5K", 3_400_000 → "3.4M". */
|
|
96
|
+
export function formatCount(n: number): string {
|
|
97
|
+
if (n < 10_000) return n.toLocaleString("en-US");
|
|
98
|
+
if (n < 1_000_000) return `${trimZero((n / 1000).toFixed(1))}K`;
|
|
99
|
+
return `${trimZero((n / 1_000_000).toFixed(1))}M`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function trimZero(s: string): string {
|
|
103
|
+
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** "1 like", "2 likes". */
|
|
107
|
+
export function plural(n: number, one: string, many = `${one}s`): string {
|
|
108
|
+
return `${formatCount(n)} ${n === 1 ? one : many}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const MINUTE = 60_000;
|
|
112
|
+
const HOUR = 60 * MINUTE;
|
|
113
|
+
const DAY = 24 * HOUR;
|
|
114
|
+
const WEEK = 7 * DAY;
|
|
115
|
+
|
|
116
|
+
/** Short age: "now", "5m", "3h", "4d", "2w", then a date. */
|
|
117
|
+
export function timeAgo(iso: string, now: number = Date.now()): string {
|
|
118
|
+
const then = Date.parse(iso);
|
|
119
|
+
if (Number.isNaN(then)) return "";
|
|
120
|
+
const age = Math.max(0, now - then);
|
|
121
|
+
if (age < MINUTE) return "now";
|
|
122
|
+
if (age < HOUR) return `${Math.floor(age / MINUTE)}m`;
|
|
123
|
+
if (age < DAY) return `${Math.floor(age / HOUR)}h`;
|
|
124
|
+
if (age < WEEK) return `${Math.floor(age / DAY)}d`;
|
|
125
|
+
if (age < 5 * WEEK) return `${Math.floor(age / WEEK)}w`;
|
|
126
|
+
const date = new Date(then);
|
|
127
|
+
const sameYear = date.getUTCFullYear() === new Date(now).getUTCFullYear();
|
|
128
|
+
return date.toLocaleDateString("en-US", {
|
|
129
|
+
month: "short",
|
|
130
|
+
day: "numeric",
|
|
131
|
+
...(sameYear ? {} : { year: "numeric" }),
|
|
132
|
+
timeZone: "UTC",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A caption or comment split into text and @mentions, so a mention can link to
|
|
138
|
+
* the profile. Only names that pass `usernameProblem` become links.
|
|
139
|
+
*/
|
|
140
|
+
export type TextPart = { kind: "text"; text: string } | { kind: "mention"; username: string };
|
|
141
|
+
|
|
142
|
+
export function splitMentions(text: string): TextPart[] {
|
|
143
|
+
const parts: TextPart[] = [];
|
|
144
|
+
const pattern = /@([a-z0-9._]{3,30})/gi;
|
|
145
|
+
let last = 0;
|
|
146
|
+
for (const match of text.matchAll(pattern)) {
|
|
147
|
+
const username = match[1].toLowerCase().replace(/\.+$/, "");
|
|
148
|
+
if (usernameProblem(username)) continue;
|
|
149
|
+
const start = match.index ?? 0;
|
|
150
|
+
if (start > last) parts.push({ kind: "text", text: text.slice(last, start) });
|
|
151
|
+
parts.push({ kind: "mention", username });
|
|
152
|
+
last = start + 1 + username.length;
|
|
153
|
+
}
|
|
154
|
+
if (last < text.length) parts.push({ kind: "text", text: text.slice(last) });
|
|
155
|
+
return parts;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Who sees what
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export function newestFirst<T extends { createdAt: string }>(rows: T[]): T[] {
|
|
163
|
+
return [...rows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The user ids `userId` follows. */
|
|
167
|
+
export function followingOf(follows: Follow[], userId: string | null | undefined): Set<string> {
|
|
168
|
+
const out = new Set<string>();
|
|
169
|
+
if (!userId) return out;
|
|
170
|
+
for (const f of follows) if (f.followerId === userId) out.add(f.followingId);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The home feed: posts by the people you follow and your own, newest first.
|
|
176
|
+
* Someone who follows nobody yet sees every post, so a new account does not
|
|
177
|
+
* open on an empty page.
|
|
178
|
+
*/
|
|
179
|
+
export function homeFeed(posts: Post[], following: Set<string>, me: string | null | undefined): { posts: Post[]; everyone: boolean } {
|
|
180
|
+
if (following.size === 0) return { posts: newestFirst(posts), everyone: true };
|
|
181
|
+
return {
|
|
182
|
+
posts: newestFirst(posts.filter((p) => following.has(p.authorId) || p.authorId === me)),
|
|
183
|
+
everyone: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Accounts to suggest: not you, not already followed, most followed first. */
|
|
188
|
+
export function suggestions(
|
|
189
|
+
profiles: Profile[],
|
|
190
|
+
follows: Follow[],
|
|
191
|
+
following: Set<string>,
|
|
192
|
+
me: string | null | undefined,
|
|
193
|
+
limit = 5,
|
|
194
|
+
): Profile[] {
|
|
195
|
+
const followers = new Map<string, number>();
|
|
196
|
+
for (const f of follows) followers.set(f.followingId, (followers.get(f.followingId) ?? 0) + 1);
|
|
197
|
+
return profiles
|
|
198
|
+
.filter((p) => p.userId !== me && !following.has(p.userId))
|
|
199
|
+
.sort((a, b) => (followers.get(b.userId) ?? 0) - (followers.get(a.userId) ?? 0) || a.username.localeCompare(b.username))
|
|
200
|
+
.slice(0, limit);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** People who posted in the last `hours`, most recent first, one entry each. */
|
|
204
|
+
export function recentPosters(posts: Post[], now: number = Date.now(), hours = 48): string[] {
|
|
205
|
+
const cutoff = now - hours * HOUR;
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
const out: string[] = [];
|
|
208
|
+
for (const post of newestFirst(posts)) {
|
|
209
|
+
if (Date.parse(post.createdAt) < cutoff) break;
|
|
210
|
+
if (seen.has(post.authorId)) continue;
|
|
211
|
+
seen.add(post.authorId);
|
|
212
|
+
out.push(post.authorId);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function countBy<T>(rows: T[], key: (row: T) => string): Map<string, number> {
|
|
218
|
+
const out = new Map<string, number>();
|
|
219
|
+
for (const row of rows) {
|
|
220
|
+
const k = key(row);
|
|
221
|
+
out.set(k, (out.get(k) ?? 0) + 1);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function groupBy<T>(rows: T[], key: (row: T) => string): Map<string, T[]> {
|
|
227
|
+
const out = new Map<string, T[]>();
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
const k = key(row);
|
|
230
|
+
const list = out.get(k);
|
|
231
|
+
if (list) list.push(row);
|
|
232
|
+
else out.set(k, [row]);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A photo URL a post may use: an uploaded file, a file in public/, or https. */
|
|
238
|
+
export function isPostImageUrl(url: string): boolean {
|
|
239
|
+
if (url.startsWith("/api/files/") || url.startsWith("/images/")) return !url.includes("..");
|
|
240
|
+
try {
|
|
241
|
+
return new URL(url).protocol === "https:";
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatCount,
|
|
4
|
+
homeFeed,
|
|
5
|
+
isPostImageUrl,
|
|
6
|
+
normalizeUsername,
|
|
7
|
+
recentPosters,
|
|
8
|
+
splitMentions,
|
|
9
|
+
starterUsername,
|
|
10
|
+
suggestions,
|
|
11
|
+
timeAgo,
|
|
12
|
+
usernameProblem,
|
|
13
|
+
type Follow,
|
|
14
|
+
type Post,
|
|
15
|
+
type Profile,
|
|
16
|
+
} from "../lib/social";
|
|
17
|
+
import { SEED_COMMENTS, SEED_FOLLOWS, SEED_POSTS, SEED_PROFILES, likersOf, shapeSeed } from "../lib/seed";
|
|
18
|
+
|
|
19
|
+
const NOW = Date.parse("2026-09-17T12:00:00Z");
|
|
20
|
+
const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
|
|
21
|
+
const post = (id: string, authorId: string, h: number): Post => ({ id, authorId, imageUrl: `/images/posts/${id}.jpg`, createdAt: hoursAgo(h) });
|
|
22
|
+
const profile = (userId: string, username: string): Profile => ({ id: `p_${userId}`, userId, username, displayName: username });
|
|
23
|
+
const follow = (followerId: string, followingId: string): Follow => ({ id: `${followerId}>${followingId}`, followerId, followingId });
|
|
24
|
+
|
|
25
|
+
describe("usernames", () => {
|
|
26
|
+
test("accepts the usual shapes and explains refusals", () => {
|
|
27
|
+
for (const ok of ["mara.bakes", "june_surfs", "abc", "a1.b2_c3"]) expect(usernameProblem(ok)).toBeNull();
|
|
28
|
+
expect(usernameProblem("ab")).toContain("at least");
|
|
29
|
+
expect(usernameProblem("x".repeat(31))).toContain("at most");
|
|
30
|
+
expect(usernameProblem("has space")).toContain("letters");
|
|
31
|
+
expect(usernameProblem("Upper")).toContain("letters");
|
|
32
|
+
expect(usernameProblem(".dot")).toContain("start or end");
|
|
33
|
+
expect(usernameProblem("dot.")).toContain("start or end");
|
|
34
|
+
expect(usernameProblem("two..dots")).toContain("two periods");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("normalizes input and starts new accounts on a valid, stable name", () => {
|
|
38
|
+
expect(normalizeUsername(" @Mara.Bakes ")).toBe("mara.bakes");
|
|
39
|
+
const name = starterUsername("guest_4f2a9c");
|
|
40
|
+
expect(name).toBe(starterUsername("guest_4f2a9c"));
|
|
41
|
+
expect(name).not.toBe(starterUsername("guest_4f2a9d"));
|
|
42
|
+
expect(usernameProblem(name)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("labels", () => {
|
|
47
|
+
test("counts", () => {
|
|
48
|
+
expect(formatCount(0)).toBe("0");
|
|
49
|
+
expect(formatCount(1204)).toBe("1,204");
|
|
50
|
+
expect(formatCount(12_500)).toBe("12.5K");
|
|
51
|
+
expect(formatCount(40_000)).toBe("40K");
|
|
52
|
+
expect(formatCount(3_400_000)).toBe("3.4M");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("ages", () => {
|
|
56
|
+
expect(timeAgo(hoursAgo(0), NOW)).toBe("now");
|
|
57
|
+
expect(timeAgo(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe("5m");
|
|
58
|
+
expect(timeAgo(hoursAgo(3), NOW)).toBe("3h");
|
|
59
|
+
expect(timeAgo(hoursAgo(50), NOW)).toBe("2d");
|
|
60
|
+
expect(timeAgo(hoursAgo(24 * 15), NOW)).toBe("2w");
|
|
61
|
+
expect(timeAgo("2026-03-04T10:00:00Z", NOW)).toBe("Mar 4");
|
|
62
|
+
expect(timeAgo("2024-03-04T10:00:00Z", NOW)).toBe("Mar 4, 2024");
|
|
63
|
+
expect(timeAgo("not a date", NOW)).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("mentions become links only when the name is valid", () => {
|
|
67
|
+
expect(splitMentions("thanks @nora.h and @biscuit.corgi.")).toEqual([
|
|
68
|
+
{ kind: "text", text: "thanks " },
|
|
69
|
+
{ kind: "mention", username: "nora.h" },
|
|
70
|
+
{ kind: "text", text: " and " },
|
|
71
|
+
{ kind: "mention", username: "biscuit.corgi" },
|
|
72
|
+
{ kind: "text", text: "." },
|
|
73
|
+
]);
|
|
74
|
+
expect(splitMentions("email me@x or @ab")).toEqual([{ kind: "text", text: "email me@x or @ab" }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("post images: uploads, public files, or https only", () => {
|
|
78
|
+
expect(isPostImageUrl("/api/files/abc123")).toBe(true);
|
|
79
|
+
expect(isPostImageUrl("/images/posts/a.jpg")).toBe(true);
|
|
80
|
+
expect(isPostImageUrl("https://cdn.example.com/a.jpg")).toBe(true);
|
|
81
|
+
expect(isPostImageUrl("http://cdn.example.com/a.jpg")).toBe(false);
|
|
82
|
+
expect(isPostImageUrl("javascript:alert(1)")).toBe(false);
|
|
83
|
+
expect(isPostImageUrl("/images/../app.ts")).toBe(false);
|
|
84
|
+
expect(isPostImageUrl("")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("who sees what", () => {
|
|
89
|
+
const posts = [post("a", "u1", 1), post("b", "u2", 2), post("c", "me", 3), post("d", "u3", 100)];
|
|
90
|
+
|
|
91
|
+
test("someone who follows nobody sees everything", () => {
|
|
92
|
+
const feed = homeFeed(posts, new Set(), "me");
|
|
93
|
+
expect(feed.everyone).toBe(true);
|
|
94
|
+
expect(feed.posts.map((p) => p.id)).toEqual(["a", "b", "c", "d"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("otherwise the people you follow and yourself, newest first", () => {
|
|
98
|
+
const feed = homeFeed(posts, new Set(["u2", "u3"]), "me");
|
|
99
|
+
expect(feed.everyone).toBe(false);
|
|
100
|
+
expect(feed.posts.map((p) => p.id)).toEqual(["b", "c", "d"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("suggestions skip you and people you follow, most followed first", () => {
|
|
104
|
+
const profiles = [profile("me", "me.me"), profile("u1", "zed"), profile("u2", "amy"), profile("u3", "bob")];
|
|
105
|
+
const follows = [follow("u1", "u3"), follow("u2", "u3"), follow("u3", "u1"), follow("me", "u2")];
|
|
106
|
+
const out = suggestions(profiles, follows, new Set(["u2"]), "me");
|
|
107
|
+
expect(out.map((p) => p.userId)).toEqual(["u3", "u1"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("recent posters are unique and inside the window", () => {
|
|
111
|
+
const list = [post("a", "u1", 1), post("b", "u1", 2), post("c", "u2", 5), post("d", "u3", 60)];
|
|
112
|
+
expect(recentPosters(list, NOW, 48)).toEqual(["u1", "u2"]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("demo data", () => {
|
|
117
|
+
const usernames = new Set(SEED_PROFILES.map((p) => p.username));
|
|
118
|
+
|
|
119
|
+
test("every username is valid and every reference points at a real row", () => {
|
|
120
|
+
for (const u of usernames) expect(usernameProblem(u)).toBeNull();
|
|
121
|
+
const keys = new Set(SEED_POSTS.map((p) => p.key));
|
|
122
|
+
expect(keys.size).toBe(SEED_POSTS.length);
|
|
123
|
+
for (const p of SEED_POSTS) expect(usernames.has(p.author)).toBe(true);
|
|
124
|
+
for (const c of SEED_COMMENTS) {
|
|
125
|
+
expect(keys.has(c.post)).toBe(true);
|
|
126
|
+
expect(usernames.has(c.author)).toBe(true);
|
|
127
|
+
}
|
|
128
|
+
for (const [from, tos] of Object.entries(SEED_FOLLOWS)) {
|
|
129
|
+
expect(usernames.has(from)).toBe(true);
|
|
130
|
+
for (const to of tos) {
|
|
131
|
+
expect(usernames.has(to)).toBe(true);
|
|
132
|
+
expect(to).not.toBe(from);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("likes: four to eleven people, never the author, no one twice", () => {
|
|
138
|
+
for (const p of SEED_POSTS) {
|
|
139
|
+
const likers = likersOf(p);
|
|
140
|
+
expect(likers.length).toBeGreaterThanOrEqual(4);
|
|
141
|
+
expect(likers.length).toBeLessThanOrEqual(11);
|
|
142
|
+
expect(likers).not.toContain(p.author);
|
|
143
|
+
expect(new Set(likers).size).toBe(likers.length);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("shaped rows are dated in the past relative to now", () => {
|
|
148
|
+
const seed = shapeSeed(NOW);
|
|
149
|
+
expect(seed.profiles).toHaveLength(SEED_PROFILES.length);
|
|
150
|
+
for (const row of [...seed.posts.map((p) => p.row), ...seed.likes.map((l) => l.row), ...seed.comments.map((c) => c.row)]) {
|
|
151
|
+
expect(Date.parse(String(row.createdAt))).toBeLessThanOrEqual(NOW);
|
|
152
|
+
}
|
|
153
|
+
// A comment never predates its post.
|
|
154
|
+
const postTime = new Map(seed.posts.map((p) => [p.key, Date.parse(String(p.row.createdAt))]));
|
|
155
|
+
for (const c of seed.comments) expect(Date.parse(String(c.row.createdAt))).toBeGreaterThanOrEqual(postTime.get(c.post)!);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -65,7 +65,7 @@ this pass before the user has to ask.
|
|
|
65
65
|
- **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
|
|
66
66
|
- **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
|
|
67
67
|
- **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
|
|
68
|
-
- **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
|
|
68
|
+
- **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
|
|
69
69
|
|
|
70
70
|
## Testing
|
|
71
71
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
3
|
import { siteConfig } from "@/lib/site.config";
|
|
4
|
+
import { WRAP } from "@/components/marketing";
|
|
4
5
|
|
|
5
6
|
// `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
|
|
6
7
|
// URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps
|
|
7
|
-
// only the pages inside the group
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// only the pages inside the group: the name up top, a footer below, both
|
|
9
|
+
// driven by lib/site.config.ts. Routes outside the group (/login, /dashboard)
|
|
10
|
+
// render bare in the root shell. `auth.user_id` is resolved server-side from
|
|
11
|
+
// the session cookie before any HTML is sent, so the nav shows "Dashboard"
|
|
12
|
+
// once the owner is signed in and "Sign in" otherwise. No flash, no client
|
|
13
|
+
// fetch.
|
|
13
14
|
interface LayoutProps {
|
|
14
15
|
children: React.ReactNode;
|
|
15
16
|
auth: PageAuth;
|
|
@@ -17,36 +18,25 @@ interface LayoutProps {
|
|
|
17
18
|
|
|
18
19
|
export default function MarketingLayout({ children, auth }: LayoutProps) {
|
|
19
20
|
// A guest session (minted by <EnsureGuest> for the live counter) has a
|
|
20
|
-
// `guest_…` user id
|
|
21
|
-
// so it
|
|
21
|
+
// `guest_…` user id. That is an anonymous visitor, not the signed-in owner,
|
|
22
|
+
// so it must not flip the nav to "Dashboard".
|
|
22
23
|
const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
|
|
23
24
|
const { brand } = siteConfig;
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
|
-
|
|
27
|
-
<header
|
|
28
|
-
<div className=
|
|
29
|
-
<Link href="/" className="
|
|
30
|
-
|
|
31
|
-
{brand.letter}
|
|
32
|
-
</span>
|
|
33
|
-
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
34
|
-
{brand.name}
|
|
35
|
-
</span>
|
|
27
|
+
<div className="font-display flex min-h-screen flex-1 flex-col bg-paper text-ink">
|
|
28
|
+
<header>
|
|
29
|
+
<div className={`${WRAP} flex h-16 items-center justify-between`}>
|
|
30
|
+
<Link href="/" className="text-[17px] font-medium text-ink">
|
|
31
|
+
{brand.name}
|
|
36
32
|
</Link>
|
|
37
|
-
<nav
|
|
33
|
+
<nav>
|
|
38
34
|
{signedIn ? (
|
|
39
|
-
<Link
|
|
40
|
-
href="/dashboard"
|
|
41
|
-
className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
42
|
-
>
|
|
35
|
+
<Link href="/dashboard" className="text-[16px] text-ink-2 hover:text-ink">
|
|
43
36
|
Dashboard
|
|
44
37
|
</Link>
|
|
45
38
|
) : (
|
|
46
|
-
<Link
|
|
47
|
-
href="/login"
|
|
48
|
-
className="rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900"
|
|
49
|
-
>
|
|
39
|
+
<Link href="/login" className="text-[16px] text-ink-2 hover:text-ink">
|
|
50
40
|
Sign in
|
|
51
41
|
</Link>
|
|
52
42
|
)}
|
|
@@ -57,61 +47,37 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
|
|
|
57
47
|
<main className="flex-1">{children}</main>
|
|
58
48
|
|
|
59
49
|
<SiteFooter />
|
|
60
|
-
|
|
50
|
+
</div>
|
|
61
51
|
);
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
function SiteFooter() {
|
|
65
55
|
const { brand } = siteConfig;
|
|
66
56
|
return (
|
|
67
|
-
<footer
|
|
68
|
-
<div className=
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
76
|
-
{brand.name}
|
|
77
|
-
</span>
|
|
78
|
-
</Link>
|
|
79
|
-
<p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
|
|
80
|
-
{brand.footerBlurb}
|
|
81
|
-
</p>
|
|
82
|
-
</div>
|
|
83
|
-
<div className="flex items-center gap-4">
|
|
84
|
-
{brand.socials.map((s) => (
|
|
85
|
-
<a
|
|
86
|
-
key={s.label}
|
|
87
|
-
href={s.href}
|
|
88
|
-
aria-label={s.label}
|
|
89
|
-
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
90
|
-
>
|
|
91
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
92
|
-
<path d={s.path} />
|
|
93
|
-
</svg>
|
|
57
|
+
<footer>
|
|
58
|
+
<div className={`${WRAP} border-t border-rule py-10 text-[15px] text-ink-2`}>
|
|
59
|
+
<p className="max-w-md leading-relaxed">{brand.footerBlurb}</p>
|
|
60
|
+
<ul className="mt-5 flex flex-wrap gap-x-5 gap-y-2">
|
|
61
|
+
{brand.socials.map((s) => (
|
|
62
|
+
<li key={s.label}>
|
|
63
|
+
<a href={s.href} className="underline decoration-rule underline-offset-4 hover:text-ink">
|
|
64
|
+
{s.label}
|
|
94
65
|
</a>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
>
|
|
101
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
102
|
-
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
103
|
-
<path d="m3 7 9 6 9-6" />
|
|
104
|
-
</svg>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
<li>
|
|
69
|
+
<a href={`mailto:${brand.email}`} className="underline decoration-rule underline-offset-4 hover:text-ink">
|
|
70
|
+
{brand.email}
|
|
105
71
|
</a>
|
|
106
|
-
</
|
|
107
|
-
</
|
|
108
|
-
<div className="mt-
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
<div className="mt-8 flex flex-col gap-1 text-[14px] sm:flex-row sm:justify-between">
|
|
109
75
|
<span>
|
|
110
76
|
© {new Date().getFullYear()} {brand.copyrightName}
|
|
111
77
|
</span>
|
|
112
78
|
<span>
|
|
113
79
|
Built with{" "}
|
|
114
|
-
<a href="https://pylonsync.com" className="
|
|
80
|
+
<a href="https://pylonsync.com" className="underline decoration-rule underline-offset-4 hover:text-ink">
|
|
115
81
|
Pylon
|
|
116
82
|
</a>
|
|
117
83
|
</span>
|
|
@@ -22,7 +22,7 @@ export function NewsletterSignup({ newsletter }: Props) {
|
|
|
22
22
|
return (
|
|
23
23
|
<div>
|
|
24
24
|
<SubscribeForm newsletter={newsletter} />
|
|
25
|
-
<div className="mt-
|
|
25
|
+
<div className="mt-3">
|
|
26
26
|
<LiveCounter seed={newsletter.seedCount ?? 0} label={newsletter.counterLabel} />
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
@@ -52,7 +52,7 @@ function SubscribeForm({ newsletter }: Props) {
|
|
|
52
52
|
setError(
|
|
53
53
|
/valid email|INVALID_ARGS/i.test(msg)
|
|
54
54
|
? "Enter a valid email address."
|
|
55
|
-
: "Something went wrong
|
|
55
|
+
: "Something went wrong. Try again in a moment.",
|
|
56
56
|
);
|
|
57
57
|
setStatus("idle");
|
|
58
58
|
}
|
|
@@ -60,17 +60,15 @@ function SubscribeForm({ newsletter }: Props) {
|
|
|
60
60
|
|
|
61
61
|
if (status === "done") {
|
|
62
62
|
return (
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
</p>
|
|
67
|
-
</div>
|
|
63
|
+
<p className="border-l-2 border-brand pl-4 text-[17.5px] leading-[1.65] text-ink">
|
|
64
|
+
{alreadyJoined ? "You are already subscribed." : newsletter.successMessage}
|
|
65
|
+
</p>
|
|
68
66
|
);
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
return (
|
|
72
70
|
<form onSubmit={onSubmit} className="max-w-md">
|
|
73
|
-
<div className="flex
|
|
71
|
+
<div className="flex gap-2">
|
|
74
72
|
<input
|
|
75
73
|
type="email"
|
|
76
74
|
inputMode="email"
|
|
@@ -80,21 +78,19 @@ function SubscribeForm({ newsletter }: Props) {
|
|
|
80
78
|
placeholder={newsletter.emailPlaceholder}
|
|
81
79
|
aria-label="Email address"
|
|
82
80
|
required
|
|
83
|
-
className="h-11 flex-1 rounded-
|
|
81
|
+
className="h-11 min-w-0 flex-1 rounded-md border border-rule bg-white px-3.5 text-[16px] text-ink outline-none transition placeholder:text-ink-2/60 focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
84
82
|
/>
|
|
85
83
|
<button
|
|
86
84
|
type="submit"
|
|
87
85
|
disabled={status === "sending"}
|
|
88
|
-
className="inline-flex h-11 items-center justify-center rounded-
|
|
86
|
+
className="inline-flex h-11 shrink-0 items-center justify-center rounded-md bg-brand px-5 text-[16px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
89
87
|
>
|
|
90
88
|
{status === "sending" ? "Subscribing…" : newsletter.ctaLabel}
|
|
91
89
|
</button>
|
|
92
90
|
</div>
|
|
93
91
|
{error ? (
|
|
94
|
-
<p className="mt-2 text-[
|
|
95
|
-
) :
|
|
96
|
-
<p className="mt-2 text-[13px] text-zinc-400">One email a week. Unsubscribe anytime.</p>
|
|
97
|
-
)}
|
|
92
|
+
<p className="mt-2 text-[15px] text-red-700">{error}</p>
|
|
93
|
+
) : null}
|
|
98
94
|
</form>
|
|
99
95
|
);
|
|
100
96
|
}
|
|
@@ -118,21 +114,14 @@ function LiveCounterInner({ seed, label }: { seed: number; label: string }) {
|
|
|
118
114
|
return <CounterView value={seed + real} label={label} live={!loading} />;
|
|
119
115
|
}
|
|
120
116
|
|
|
117
|
+
// Plain text under the form. The fallback shows the seed until the guest
|
|
118
|
+
// session connects; `live` is set once the count is synced.
|
|
121
119
|
function CounterView({ value, label, live }: { value: number; label: string; live?: boolean }) {
|
|
122
120
|
const shown = useCountUp(value);
|
|
123
121
|
return (
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-brand/70" />
|
|
128
|
-
<span className="relative inline-flex size-2 rounded-full bg-brand" />
|
|
129
|
-
</span>
|
|
130
|
-
) : (
|
|
131
|
-
<span className="inline-flex size-2 rounded-full bg-zinc-300" />
|
|
132
|
-
)}
|
|
133
|
-
<span className="font-semibold tabular-nums text-zinc-900">{shown.toLocaleString()}</span>
|
|
134
|
-
{label}
|
|
135
|
-
</div>
|
|
122
|
+
<p className="text-[15px] text-ink-2" data-live={live ? "true" : undefined}>
|
|
123
|
+
<span className="tabular-nums text-ink">{shown.toLocaleString()}</span> {label}
|
|
124
|
+
</p>
|
|
136
125
|
);
|
|
137
126
|
}
|
|
138
127
|
|