@pylonsync/create-pylon 0.11.1 → 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.
Files changed (142) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +1 -1
  4. package/templates/agency/AGENTS.md +1 -1
  5. package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
  6. package/templates/agency/app/(marketing)/layout.tsx +36 -64
  7. package/templates/agency/app/(marketing)/not-found.tsx +6 -8
  8. package/templates/agency/app/(marketing)/page.tsx +93 -144
  9. package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
  10. package/templates/agency/app/(marketing)/work/page.tsx +20 -17
  11. package/templates/agency/app/globals.css +15 -6
  12. package/templates/agency/app.ts +10 -0
  13. package/templates/agency/components/marketing.tsx +40 -104
  14. package/templates/agency/lib/site.config.ts +34 -52
  15. package/templates/agency/public/images/hero.jpg +0 -0
  16. package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
  17. package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
  18. package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
  19. package/templates/agency/public/images/work/atlas-health.jpg +0 -0
  20. package/templates/agency/public/images/work/cohort.jpg +0 -0
  21. package/templates/agency/public/images/work/ledger.jpg +0 -0
  22. package/templates/agency/public/images/work/vela.jpg +0 -0
  23. package/templates/ai-chat/AGENTS.md +1 -1
  24. package/templates/ai-studio/AGENTS.md +1 -1
  25. package/templates/barebones/AGENTS.md +1 -1
  26. package/templates/chat/AGENTS.md +1 -1
  27. package/templates/consumer/AGENTS.md +1 -1
  28. package/templates/consumer/README.md +39 -23
  29. package/templates/consumer/app/(app)/explore/page.tsx +13 -0
  30. package/templates/consumer/app/(app)/layout.tsx +17 -0
  31. package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
  32. package/templates/consumer/app/(app)/page.tsx +15 -0
  33. package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
  34. package/templates/consumer/app/error.tsx +1 -1
  35. package/templates/consumer/app/globals.css +25 -0
  36. package/templates/consumer/app/layout.tsx +3 -10
  37. package/templates/consumer/app/llms.ts +10 -10
  38. package/templates/consumer/app/not-found.tsx +9 -22
  39. package/templates/consumer/app/robots.ts +3 -4
  40. package/templates/consumer/app/sitemap.ts +7 -21
  41. package/templates/consumer/app.ts +152 -33
  42. package/templates/consumer/components/social/app-chrome.tsx +109 -0
  43. package/templates/consumer/components/social/avatar.tsx +50 -0
  44. package/templates/consumer/components/social/comment-form.tsx +51 -0
  45. package/templates/consumer/components/social/create-post.tsx +155 -0
  46. package/templates/consumer/components/social/edit-profile.tsx +103 -0
  47. package/templates/consumer/components/social/explore-screen.tsx +23 -0
  48. package/templates/consumer/components/social/home-screen.tsx +161 -0
  49. package/templates/consumer/components/social/post-card.tsx +77 -0
  50. package/templates/consumer/components/social/post-grid.tsx +46 -0
  51. package/templates/consumer/components/social/post-parts.tsx +146 -0
  52. package/templates/consumer/components/social/post-screen.tsx +173 -0
  53. package/templates/consumer/components/social/profile-screen.tsx +146 -0
  54. package/templates/consumer/components/social/rich-text.tsx +22 -0
  55. package/templates/consumer/components/social/session.tsx +43 -0
  56. package/templates/consumer/components/social/use-social.ts +86 -0
  57. package/templates/consumer/functions/addComment.ts +27 -0
  58. package/templates/consumer/functions/createPost.ts +39 -0
  59. package/templates/consumer/functions/ensureProfile.ts +39 -0
  60. package/templates/consumer/functions/seedFeed.ts +38 -0
  61. package/templates/consumer/functions/updateProfile.ts +61 -0
  62. package/templates/consumer/lib/seed.ts +192 -0
  63. package/templates/consumer/lib/site.ts +6 -0
  64. package/templates/consumer/lib/social.ts +245 -0
  65. package/templates/consumer/tests/social.test.ts +157 -0
  66. package/templates/creator/AGENTS.md +1 -1
  67. package/templates/creator/app/(marketing)/layout.tsx +35 -69
  68. package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
  69. package/templates/creator/app/(marketing)/not-found.tsx +8 -6
  70. package/templates/creator/app/(marketing)/page.tsx +90 -106
  71. package/templates/creator/app/globals.css +14 -0
  72. package/templates/creator/app/layout.tsx +1 -0
  73. package/templates/creator/app.ts +10 -0
  74. package/templates/creator/components/marketing.tsx +28 -89
  75. package/templates/creator/lib/site.config.ts +51 -29
  76. package/templates/creator/public/images/headshot.jpg +0 -0
  77. package/templates/crm/AGENTS.md +1 -1
  78. package/templates/crm/lib/seed.ts +153 -21
  79. package/templates/directory/AGENTS.md +1 -1
  80. package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
  81. package/templates/directory/app/(marketing)/layout.tsx +37 -88
  82. package/templates/directory/app/(marketing)/not-found.tsx +11 -10
  83. package/templates/directory/app/(marketing)/page.tsx +12 -33
  84. package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
  85. package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
  86. package/templates/directory/app/globals.css +15 -4
  87. package/templates/directory/app.ts +10 -0
  88. package/templates/directory/components/marketing.tsx +27 -91
  89. package/templates/directory/lib/site.config.ts +13 -24
  90. package/templates/helpdesk/AGENTS.md +1 -1
  91. package/templates/helpdesk/lib/seed.ts +188 -104
  92. package/templates/inventory/AGENTS.md +1 -1
  93. package/templates/inventory/lib/seed.ts +175 -44
  94. package/templates/invoices/AGENTS.md +1 -1
  95. package/templates/invoices/lib/seed.ts +74 -89
  96. package/templates/local-service/AGENTS.md +1 -1
  97. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  98. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  99. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  100. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  101. package/templates/local-service/app/globals.css +11 -0
  102. package/templates/local-service/app/layout.tsx +2 -0
  103. package/templates/local-service/app.ts +9 -0
  104. package/templates/local-service/components/marketing.tsx +14 -39
  105. package/templates/local-service/lib/site.config.ts +16 -23
  106. package/templates/local-service/public/images/hero.jpg +0 -0
  107. package/templates/marketplace/AGENTS.md +1 -1
  108. package/templates/marketplace/client/SellForm.tsx +2 -0
  109. package/templates/projects/AGENTS.md +1 -1
  110. package/templates/projects/lib/seed.ts +103 -32
  111. package/templates/restaurant/AGENTS.md +1 -1
  112. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  113. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  114. package/templates/restaurant/app/globals.css +11 -0
  115. package/templates/restaurant/app/layout.tsx +1 -0
  116. package/templates/restaurant/app.ts +10 -0
  117. package/templates/restaurant/components/marketing.tsx +10 -0
  118. package/templates/restaurant/lib/site.config.ts +16 -14
  119. package/templates/restaurant/public/images/bar.jpg +0 -0
  120. package/templates/restaurant/public/images/branzino.jpg +0 -0
  121. package/templates/restaurant/public/images/hearth.jpg +0 -0
  122. package/templates/restaurant/public/images/hero.jpg +0 -0
  123. package/templates/saas/AGENTS.md +1 -1
  124. package/templates/shop/AGENTS.md +1 -1
  125. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  126. package/templates/shop/public/images/products/linen.jpg +0 -0
  127. package/templates/todo/AGENTS.md +1 -1
  128. package/templates/waitlist/AGENTS.md +1 -1
  129. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  130. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  131. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  132. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  133. package/templates/waitlist/app/globals.css +23 -0
  134. package/templates/waitlist/app/layout.tsx +1 -0
  135. package/templates/waitlist/app.ts +18 -0
  136. package/templates/waitlist/components/marketing.tsx +67 -81
  137. package/templates/waitlist/lib/site.config.ts +52 -59
  138. package/templates/consumer/app/feed-client.tsx +0 -159
  139. package/templates/consumer/app/page.tsx +0 -27
  140. package/templates/consumer/functions/_keep.ts +0 -13
  141. package/templates/consumer/functions/seedPosts.ts +0 -56
  142. package/templates/consumer/tests/example.test.ts +0 -12
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import {
7
+ countBy,
8
+ followingOf,
9
+ groupBy,
10
+ newestFirst,
11
+ type Comment,
12
+ type Follow,
13
+ type Like,
14
+ type Post,
15
+ type Profile,
16
+ type Save,
17
+ } from "@/lib/social";
18
+
19
+ /**
20
+ * Everything the screens read, joined once. Each `db.useQuery` is live, so a
21
+ * like, comment, or follow from another tab shows up here without a reload.
22
+ * Fine for a demo-sized network; a large one would query per screen with
23
+ * `where` and `limit` instead of reading every row.
24
+ */
25
+ export function useSocial() {
26
+ const { userId } = useAuth();
27
+ const profiles = db.useQuery<Profile>("Profile");
28
+ const posts = db.useQuery<Post>("Post");
29
+ const likes = db.useQuery<Like>("Like");
30
+ const comments = db.useQuery<Comment>("Comment");
31
+ const follows = db.useQuery<Follow>("Follow");
32
+ const saves = db.useQuery<Save>("Save");
33
+
34
+ return useMemo(() => {
35
+ const me = userId ?? null;
36
+ const profileByUser = new Map(profiles.data.map((p) => [p.userId, p]));
37
+ const profileByUsername = new Map(profiles.data.map((p) => [p.username, p]));
38
+ const myLikes = new Map(likes.data.filter((l) => l.userId === me).map((l) => [l.postId, l]));
39
+ const mySaves = new Map(saves.data.filter((s) => s.userId === me).map((s) => [s.postId, s]));
40
+ const myFollows = new Map(follows.data.filter((f) => f.followerId === me).map((f) => [f.followingId, f]));
41
+ return {
42
+ me,
43
+ myProfile: me ? profileByUser.get(me) ?? null : null,
44
+ loading: posts.loading && posts.data.length === 0,
45
+ profiles: profiles.data,
46
+ posts: newestFirst(posts.data),
47
+ follows: follows.data,
48
+ profileByUser,
49
+ profileByUsername,
50
+ likeCount: countBy(likes.data, (l) => l.postId),
51
+ commentsByPost: groupBy(newestFirst(comments.data).reverse(), (c) => c.postId),
52
+ postsByAuthor: groupBy(newestFirst(posts.data), (p) => p.authorId),
53
+ followerCount: countBy(follows.data, (f) => f.followingId),
54
+ followingCount: countBy(follows.data, (f) => f.followerId),
55
+ following: followingOf(follows.data, me),
56
+ myLikes,
57
+ mySaves,
58
+ myFollows,
59
+ };
60
+ }, [userId, profiles.data, posts.data, posts.loading, likes.data, comments.data, follows.data, saves.data]);
61
+ }
62
+
63
+ export type Social = ReturnType<typeof useSocial>;
64
+
65
+ /** Writes. Likes, saves, and follows are direct; posts and comments go through functions. */
66
+ export const actions = {
67
+ toggleLike(social: Social, postId: string) {
68
+ const mine = social.myLikes.get(postId);
69
+ if (mine) void db.delete("Like", mine.id);
70
+ else if (social.me) void db.insert("Like", { postId, userId: social.me });
71
+ },
72
+ like(social: Social, postId: string) {
73
+ if (social.me && !social.myLikes.has(postId)) void db.insert("Like", { postId, userId: social.me });
74
+ },
75
+ toggleSave(social: Social, postId: string) {
76
+ const mine = social.mySaves.get(postId);
77
+ if (mine) void db.delete("Save", mine.id);
78
+ else if (social.me) void db.insert("Save", { postId, userId: social.me });
79
+ },
80
+ toggleFollow(social: Social, userId: string) {
81
+ if (!social.me || userId === social.me) return;
82
+ const mine = social.myFollows.get(userId);
83
+ if (mine) void db.delete("Follow", mine.id);
84
+ else void db.insert("Follow", { followingId: userId, followerId: social.me });
85
+ },
86
+ };
@@ -0,0 +1,27 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { COMMENT_MAX } from "../lib/social";
3
+
4
+ /** Comment on a post. The post must exist and the text must fit. */
5
+ export default mutation<{ postId: string; text: string }, { id: string }>({
6
+ // Guest sessions count: a visitor can post without signing up.
7
+ auth: "guest",
8
+ args: { postId: v.string(), text: v.string() },
9
+ async handler(ctx, args) {
10
+ const userId = ctx.auth.userId;
11
+ if (!userId) throw ctx.error("AUTH_REQUIRED", "Open the app to start a session first.");
12
+ const text = args.text.trim();
13
+ if (!text) throw ctx.error("INVALID_COMMENT", "Write something first.");
14
+ if (text.length > COMMENT_MAX) throw ctx.error("INVALID_COMMENT", `Use at most ${COMMENT_MAX} characters.`);
15
+ const post = await ctx.db.get("Post", args.postId);
16
+ if (!post) throw ctx.error("NOT_FOUND", "That post was deleted.");
17
+ // The Comment policy refuses client inserts; this function is the
18
+ // checked path, so it writes past the policy on purpose.
19
+ const id = await ctx.db.unsafe.insert("Comment", {
20
+ postId: args.postId,
21
+ authorId: userId,
22
+ text,
23
+ createdAt: new Date().toISOString(),
24
+ });
25
+ return { id };
26
+ },
27
+ });
@@ -0,0 +1,39 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { CAPTION_MAX, isPostImageUrl } from "../lib/social";
3
+
4
+ /**
5
+ * Publish a photo. The client uploads the file first (uploadFile with
6
+ * visibility "public", so every visitor can load it) and sends its URL here.
7
+ * The URL, caption length, and crop shape are checked on the server.
8
+ */
9
+ export default mutation<
10
+ { imageUrl: string; caption?: string; shape?: string },
11
+ { id: string }
12
+ >({
13
+ // Guest sessions count: a visitor can post without signing up.
14
+ auth: "guest",
15
+ args: {
16
+ imageUrl: v.string(),
17
+ caption: v.optional(v.string()),
18
+ shape: v.optional(v.string()),
19
+ },
20
+ async handler(ctx, args) {
21
+ const userId = ctx.auth.userId;
22
+ if (!userId) throw ctx.error("AUTH_REQUIRED", "Open the app to start a session first.");
23
+ const imageUrl = args.imageUrl.trim();
24
+ if (!isPostImageUrl(imageUrl)) throw ctx.error("INVALID_IMAGE", "Upload the photo again.");
25
+ const caption = (args.caption ?? "").trim();
26
+ if (caption.length > CAPTION_MAX) throw ctx.error("INVALID_CAPTION", `Use at most ${CAPTION_MAX} characters.`);
27
+ const shape = args.shape === "portrait" ? "portrait" : "square";
28
+ // The Post policy refuses client inserts; this function is the checked
29
+ // path, so it writes past the policy on purpose.
30
+ const id = await ctx.db.unsafe.insert("Post", {
31
+ authorId: userId,
32
+ imageUrl,
33
+ caption: caption || null,
34
+ shape,
35
+ createdAt: new Date().toISOString(),
36
+ });
37
+ return { id };
38
+ },
39
+ });
@@ -0,0 +1,39 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { starterUsername } from "../lib/social";
3
+
4
+ /**
5
+ * The caller's profile, created on first use. Every page calls this once a
6
+ * session exists, so a visitor has a username before they post, like, or
7
+ * comment. A starter username is `visitor_xxxxxx`; the profile page can
8
+ * change it.
9
+ */
10
+ export default mutation<Record<string, never>, { id: string; username: string; created: boolean }>({
11
+ // Guest sessions count: a visitor can post without signing up.
12
+ auth: "guest",
13
+ args: {},
14
+ async handler(ctx) {
15
+ const userId = ctx.auth.userId;
16
+ if (!userId) throw ctx.error("AUTH_REQUIRED", "Open the app to start a session first.");
17
+ // Two tabs opening at once must not create two profiles.
18
+ await ctx.db.advisoryLock(`profile:${userId}`);
19
+ const [existing] = await ctx.db.query("Profile", { userId, $limit: 1 });
20
+ if (existing) return { id: String(existing.id), username: String(existing.username), created: false };
21
+
22
+ const base = starterUsername(userId);
23
+ for (let attempt = 0; attempt < 5; attempt++) {
24
+ const username = attempt === 0 ? base : `${base}${attempt}`;
25
+ const [taken] = await ctx.db.query("Profile", { username, $limit: 1 });
26
+ if (taken) continue;
27
+ // The Profile policy refuses client writes; this function is the
28
+ // checked path, so it writes past the policy on purpose.
29
+ const id = await ctx.db.unsafe.insert("Profile", {
30
+ userId,
31
+ username,
32
+ displayName: "Visitor",
33
+ createdAt: new Date().toISOString(),
34
+ });
35
+ return { id, username, created: true };
36
+ }
37
+ throw ctx.error("USERNAME_UNAVAILABLE", "Could not pick a free username. Try again.");
38
+ },
39
+ });
@@ -0,0 +1,38 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { shapeSeed } from "../lib/seed";
3
+
4
+ /**
5
+ * Load the demo people, posts, likes, comments, and follows once. The feed
6
+ * calls this on mount; it returns at once when the demo data exists, and a
7
+ * lock keeps two first visits from seeding twice.
8
+ *
9
+ * Public so an anonymous first visitor seeds it. `unsafe.insert` writes past
10
+ * the policies, which only accept the caller's own id, so the posts read as
11
+ * twelve different people. It only writes lib/seed.ts. Delete this function
12
+ * and lib/seed.ts when you launch.
13
+ */
14
+ export default mutation<Record<string, never>, { seeded: boolean }>({
15
+ auth: "public",
16
+ args: {},
17
+ async handler(ctx) {
18
+ await ctx.db.advisoryLock("consumer_seed_feed");
19
+ const existing = await ctx.db.unsafe.query("Profile", { userId: "demo_mara_bakes", $limit: 1 });
20
+ if (existing.length > 0) return { seeded: false };
21
+
22
+ const seed = shapeSeed();
23
+ for (const profile of seed.profiles) await ctx.db.unsafe.insert("Profile", profile);
24
+
25
+ const postIds = new Map<string, string>();
26
+ for (const post of seed.posts) {
27
+ postIds.set(post.key, await ctx.db.unsafe.insert("Post", post.row));
28
+ }
29
+ for (const like of seed.likes) {
30
+ await ctx.db.unsafe.insert("Like", { ...like.row, postId: postIds.get(like.post) });
31
+ }
32
+ for (const comment of seed.comments) {
33
+ await ctx.db.unsafe.insert("Comment", { ...comment.row, postId: postIds.get(comment.post) });
34
+ }
35
+ for (const follow of seed.follows) await ctx.db.unsafe.insert("Follow", follow);
36
+ return { seeded: true };
37
+ },
38
+ });
@@ -0,0 +1,61 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import {
3
+ BIO_MAX,
4
+ DISPLAY_NAME_MAX,
5
+ isPostImageUrl,
6
+ normalizeUsername,
7
+ usernameProblem,
8
+ } from "../lib/social";
9
+
10
+ /**
11
+ * Change the caller's username, name, bio, or avatar. A function rather
12
+ * than a client update so the username rules and uniqueness are checked on
13
+ * the server; the Profile policy refuses direct writes.
14
+ */
15
+ export default mutation<
16
+ { username: string; displayName: string; bio?: string; avatarUrl?: string },
17
+ { username: string }
18
+ >({
19
+ // Guest sessions count: a visitor can post without signing up.
20
+ auth: "guest",
21
+ args: {
22
+ username: v.string(),
23
+ displayName: v.string(),
24
+ bio: v.optional(v.string()),
25
+ // An empty string removes the photo.
26
+ avatarUrl: v.optional(v.string()),
27
+ },
28
+ async handler(ctx, args) {
29
+ const userId = ctx.auth.userId;
30
+ if (!userId) throw ctx.error("AUTH_REQUIRED", "Open the app to start a session first.");
31
+ const [profile] = await ctx.db.query("Profile", { userId, $limit: 1 });
32
+ if (!profile) throw ctx.error("NOT_FOUND", "Open the app once to create your profile first.");
33
+
34
+ const username = normalizeUsername(args.username);
35
+ const problem = usernameProblem(username);
36
+ if (problem) throw ctx.error("INVALID_USERNAME", problem);
37
+ if (username !== profile.username) {
38
+ await ctx.db.advisoryLock(`username:${username}`);
39
+ const [taken] = await ctx.db.query("Profile", { username, $limit: 1 });
40
+ if (taken) throw ctx.error("USERNAME_TAKEN", `@${username} is taken.`);
41
+ }
42
+
43
+ const displayName = args.displayName.trim();
44
+ if (!displayName) throw ctx.error("INVALID_NAME", "Add a name.");
45
+ if (displayName.length > DISPLAY_NAME_MAX) throw ctx.error("INVALID_NAME", `Use at most ${DISPLAY_NAME_MAX} characters for the name.`);
46
+ const bio = (args.bio ?? "").trim();
47
+ if (bio.length > BIO_MAX) throw ctx.error("INVALID_BIO", `Use at most ${BIO_MAX} characters for the bio.`);
48
+ const avatarUrl = args.avatarUrl ? args.avatarUrl.trim() : null;
49
+ if (avatarUrl && !isPostImageUrl(avatarUrl)) throw ctx.error("INVALID_IMAGE", "Upload the photo again.");
50
+
51
+ // The Profile policy refuses client writes; this function is the
52
+ // checked path, so it writes past the policy on purpose.
53
+ await ctx.db.unsafe.update("Profile", String(profile.id), {
54
+ username,
55
+ displayName,
56
+ bio: bio || null,
57
+ avatarUrl,
58
+ });
59
+ return { username };
60
+ },
61
+ });
@@ -0,0 +1,192 @@
1
+ // Demo data for a new app: twelve people who post, follow each other, like,
2
+ // and comment, so the first visit opens on a feed that looks lived in.
3
+ //
4
+ // Photos live in public/images (avatars/ and posts/). Demo user ids start
5
+ // with "demo_" so they never collide with a real or guest account.
6
+ // Pure data + pure shaping; functions/seedFeed.ts is a thin wrapper.
7
+
8
+ export interface SeedProfile {
9
+ username: string;
10
+ displayName: string;
11
+ bio: string;
12
+ }
13
+
14
+ export interface SeedPost {
15
+ key: string;
16
+ author: string;
17
+ caption: string;
18
+ shape: "square" | "portrait";
19
+ /** Hours ago. */
20
+ age: number;
21
+ }
22
+
23
+ export interface SeedComment {
24
+ post: string;
25
+ author: string;
26
+ text: string;
27
+ /** Hours after the post. */
28
+ after: number;
29
+ }
30
+
31
+ export const SEED_PROFILES: SeedProfile[] = [
32
+ { username: "mara.bakes", displayName: "Mara Lindqvist", bio: "Sourdough, mostly. Stockholm." },
33
+ { username: "theo.climbs", displayName: "Theo Adeyemi", bio: "Bouldering most evenings. Route setter at Blocworks." },
34
+ { username: "ines.clay", displayName: "Inés Moreno", bio: "Wheel-thrown stoneware from a small studio in Valencia." },
35
+ { username: "kenji.rides", displayName: "Kenji Watanabe", bio: "Gravel on weekends, commuting on weekdays." },
36
+ { username: "lou.streets", displayName: "Louise Carter", bio: "35mm film, mostly Brooklyn." },
37
+ { username: "sam.grows", displayName: "Sam Okafor", bio: "Allotment 14B. Tomatoes, beans, too many courgettes." },
38
+ { username: "rosa.roasts", displayName: "Rosa Delgado", bio: "Head roaster at a small shop in Oakland." },
39
+ { username: "biscuit.corgi", displayName: "Biscuit", bio: "Corgi. Professional napper. Managed by @nora.h" },
40
+ { username: "nora.h", displayName: "Nora Hughes", bio: "Product designer. Biscuit's person." },
41
+ { username: "ari.wanders", displayName: "Ari Cohen", bio: "Trains over planes." },
42
+ { username: "dev.draws", displayName: "Dev Patel", bio: "Architect. A sketch every morning before work." },
43
+ { username: "june.surfs", displayName: "June Tanaka", bio: "Cold water surfer on the Oregon coast." },
44
+ ];
45
+
46
+ export const SEED_POSTS: SeedPost[] = [
47
+ { key: "mara-loaf", author: "mara.bakes", caption: "Sunday loaf. 78% hydration and finally an ear I am happy with.", shape: "portrait", age: 3 },
48
+ { key: "june-dawn", author: "june.surfs", caption: "Glassy at 6am and nobody else out. Worth the cold hands.", shape: "portrait", age: 5 },
49
+ { key: "biscuit-couch", author: "biscuit.corgi", caption: "Guarding the couch from nothing in particular.", shape: "square", age: 8 },
50
+ { key: "rosa-roaster", author: "rosa.roasts", caption: "New Ethiopia lot on the roaster today. Blueberry and jasmine in the cup.", shape: "square", age: 11 },
51
+ { key: "ines-glaze", author: "ines.clay", caption: "Out of the kiln: the speckled oat glaze on a batch of mugs. Shop update Friday.", shape: "portrait", age: 14 },
52
+ { key: "lou-crosswalk", author: "lou.streets", caption: "Bedford Ave, late afternoon. Portra 400.", shape: "portrait", age: 20 },
53
+ { key: "dev-sketch", author: "dev.draws", caption: "Morning sketch of the old library stairs. Twenty minutes, one pen.", shape: "square", age: 26 },
54
+ { key: "kenji-gravel", author: "kenji.rides", caption: "82 km, 1,400 m of climbing, one flat. Good day.", shape: "portrait", age: 30 },
55
+ { key: "sam-tomatoes", author: "sam.grows", caption: "First proper harvest of the year. The yellow ones are Sungold.", shape: "square", age: 34 },
56
+ { key: "theo-boulder", author: "theo.climbs", caption: "Set a new blue circuit this week. This one is the crux of number 6.", shape: "portrait", age: 40 },
57
+ { key: "ari-train", author: "ari.wanders", caption: "Night train from Vienna to Venice. Woke up to this.", shape: "portrait", age: 46 },
58
+ { key: "nora-desk", author: "nora.h", caption: "New desk setup. @biscuit.corgi approved the rug.", shape: "square", age: 52 },
59
+ { key: "mara-buns", author: "mara.bakes", caption: "Cardamom buns for the neighbours. Kept two.", shape: "square", age: 60 },
60
+ { key: "june-board", author: "june.surfs", caption: "Waxed and ready for tomorrow.", shape: "square", age: 70 },
61
+ { key: "rosa-latte", author: "rosa.roasts", caption: "Tuesday latte art practice. Getting there.", shape: "square", age: 78 },
62
+ { key: "ines-wheel", author: "ines.clay", caption: "Trimming feet on a batch of bowls. The quiet part of the week.", shape: "portrait", age: 90 },
63
+ { key: "lou-diner", author: "lou.streets", caption: "Counter seats at the diner on Graham. Coffee refills forever.", shape: "square", age: 100 },
64
+ { key: "biscuit-beach", author: "biscuit.corgi", caption: "First time at the beach. Sand everywhere. No regrets.", shape: "portrait", age: 110 },
65
+ { key: "dev-model", author: "dev.draws", caption: "Card model for the community centre competition entry.", shape: "square", age: 124 },
66
+ { key: "kenji-coffee", author: "kenji.rides", caption: "Mid-ride coffee stop. The bike rests too.", shape: "square", age: 140 },
67
+ { key: "sam-shed", author: "sam.grows", caption: "Seed trays in the shed. Kale, chard, and far too much basil.", shape: "portrait", age: 155 },
68
+ { key: "theo-chalk", author: "theo.climbs", caption: "Chalk bag, tape, and a very tired pair of shoes.", shape: "square", age: 170 },
69
+ { key: "ari-market", author: "ari.wanders", caption: "Morning market in Palermo. I bought too many lemons.", shape: "square", age: 190 },
70
+ { key: "nora-sketches", author: "nora.h", caption: "Paper first, screens later. Wireframes for the booking flow.", shape: "square", age: 210 },
71
+ { key: "mara-starter", author: "mara.bakes", caption: "Meet Olof, the starter. Eleven years old this month.", shape: "portrait", age: 240 },
72
+ { key: "june-van", author: "june.surfs", caption: "Home for the weekend.", shape: "square", age: 280 },
73
+ { key: "ines-studio", author: "ines.clay", caption: "The studio shelves on a good week.", shape: "portrait", age: 320 },
74
+ { key: "lou-rain", author: "lou.streets", caption: "Rain on the J train platform.", shape: "portrait", age: 360 },
75
+ ];
76
+
77
+ export const SEED_COMMENTS: SeedComment[] = [
78
+ { post: "mara-loaf", author: "rosa.roasts", text: "That crumb. Trading you a bag of the new Ethiopia for one.", after: 0.5 },
79
+ { post: "mara-loaf", author: "sam.grows", text: "What flour are you using?", after: 1 },
80
+ { post: "mara-loaf", author: "mara.bakes", text: "@sam.grows 80% bread flour, 20% whole wheat from the mill down the road.", after: 1.5 },
81
+ { post: "june-dawn", author: "kenji.rides", text: "Unreal colours.", after: 1 },
82
+ { post: "june-dawn", author: "ari.wanders", text: "Which beach is this?", after: 2 },
83
+ { post: "biscuit-couch", author: "nora.h", text: "He has not moved in three hours.", after: 0.2 },
84
+ { post: "biscuit-couch", author: "lou.streets", text: "A very serious job.", after: 2 },
85
+ { post: "biscuit-couch", author: "theo.climbs", text: "Those ears.", after: 3 },
86
+ { post: "rosa-roaster", author: "mara.bakes", text: "Saving me a bag?", after: 1 },
87
+ { post: "rosa-roaster", author: "rosa.roasts", text: "@mara.bakes already set aside.", after: 1.2 },
88
+ { post: "ines-glaze", author: "dev.draws", text: "The speckle on that glaze is lovely.", after: 2 },
89
+ { post: "ines-glaze", author: "nora.h", text: "Setting an alarm for Friday.", after: 3 },
90
+ { post: "lou-crosswalk", author: "ari.wanders", text: "The light in this one.", after: 4 },
91
+ { post: "dev-sketch", author: "ines.clay", text: "Twenty minutes? Show-off.", after: 1 },
92
+ { post: "kenji-gravel", author: "june.surfs", text: "Where was the flat?", after: 2 },
93
+ { post: "kenji-gravel", author: "kenji.rides", text: "@june.surfs about 5 km from the end, of course.", after: 3 },
94
+ { post: "sam-tomatoes", author: "mara.bakes", text: "Tomato and bread season.", after: 1 },
95
+ { post: "theo-boulder", author: "kenji.rides", text: "Looks sandbagged.", after: 2 },
96
+ { post: "theo-boulder", author: "theo.climbs", text: "@kenji.rides it is a fair blue. Mostly.", after: 2.5 },
97
+ { post: "ari-train", author: "lou.streets", text: "Adding this to the list.", after: 5 },
98
+ { post: "nora-desk", author: "dev.draws", text: "Where is the lamp from?", after: 1 },
99
+ { post: "mara-buns", author: "rosa.roasts", text: "Recipe please.", after: 2 },
100
+ { post: "biscuit-beach", author: "june.surfs", text: "Surf lessons next.", after: 1 },
101
+ { post: "dev-model", author: "ines.clay", text: "Good luck with the entry.", after: 3 },
102
+ { post: "sam-shed", author: "mara.bakes", text: "Pesto for the whole street then.", after: 4 },
103
+ { post: "mara-starter", author: "biscuit.corgi", text: "Can I eat Olof.", after: 2 },
104
+ { post: "mara-starter", author: "mara.bakes", text: "@biscuit.corgi no.", after: 2.2 },
105
+ ];
106
+
107
+ /** Who each demo user follows, as username → usernames. */
108
+ export const SEED_FOLLOWS: Record<string, string[]> = {
109
+ "mara.bakes": ["rosa.roasts", "sam.grows", "ines.clay", "biscuit.corgi", "lou.streets"],
110
+ "theo.climbs": ["kenji.rides", "june.surfs", "ari.wanders", "biscuit.corgi"],
111
+ "ines.clay": ["dev.draws", "mara.bakes", "nora.h", "lou.streets"],
112
+ "kenji.rides": ["theo.climbs", "june.surfs", "rosa.roasts", "ari.wanders"],
113
+ "lou.streets": ["ari.wanders", "dev.draws", "biscuit.corgi", "ines.clay"],
114
+ "sam.grows": ["mara.bakes", "rosa.roasts", "biscuit.corgi"],
115
+ "rosa.roasts": ["mara.bakes", "kenji.rides", "ines.clay", "sam.grows"],
116
+ "biscuit.corgi": ["nora.h", "june.surfs"],
117
+ "nora.h": ["biscuit.corgi", "dev.draws", "ines.clay", "lou.streets", "mara.bakes"],
118
+ "ari.wanders": ["lou.streets", "june.surfs", "kenji.rides", "theo.climbs"],
119
+ "dev.draws": ["ines.clay", "nora.h", "lou.streets"],
120
+ "june.surfs": ["kenji.rides", "biscuit.corgi", "ari.wanders", "theo.climbs"],
121
+ };
122
+
123
+ export const demoUserId = (username: string) => `demo_${username.replace(/\./g, "_")}`;
124
+ export const avatarPath = (username: string) => `/images/avatars/${username}.jpg`;
125
+ export const postImagePath = (key: string) => `/images/posts/${key}.jpg`;
126
+
127
+ /**
128
+ * Which demo users like a post: a stable pick from everyone but the author,
129
+ * between four and eleven people, so counts differ from post to post.
130
+ */
131
+ export function likersOf(post: SeedPost): string[] {
132
+ const others = SEED_PROFILES.map((p) => p.username).filter((u) => u !== post.author);
133
+ let h = 2166136261;
134
+ for (let i = 0; i < post.key.length; i++) {
135
+ h ^= post.key.charCodeAt(i);
136
+ h = Math.imul(h, 16777619);
137
+ }
138
+ const count = 4 + ((h >>> 0) % (others.length - 3));
139
+ const start = (h >>> 8) % others.length;
140
+ return Array.from({ length: count }, (_, i) => others[(start + i) % others.length]);
141
+ }
142
+
143
+ export interface ShapedSeed {
144
+ profiles: Record<string, unknown>[];
145
+ posts: Array<{ key: string; row: Record<string, unknown> }>;
146
+ likes: Array<{ post: string; row: Record<string, unknown> }>;
147
+ comments: Array<{ post: string; row: Record<string, unknown> }>;
148
+ follows: Record<string, unknown>[];
149
+ }
150
+
151
+ export function shapeSeed(now: number = Date.now()): ShapedSeed {
152
+ const hoursAgo = (h: number) => new Date(now - h * 3_600_000).toISOString();
153
+ const postAge = new Map(SEED_POSTS.map((p) => [p.key, p.age]));
154
+
155
+ return {
156
+ profiles: SEED_PROFILES.map((p, i) => ({
157
+ userId: demoUserId(p.username),
158
+ username: p.username,
159
+ displayName: p.displayName,
160
+ bio: p.bio,
161
+ avatarUrl: avatarPath(p.username),
162
+ createdAt: hoursAgo(2000 - i * 40),
163
+ })),
164
+ posts: SEED_POSTS.map((p) => ({
165
+ key: p.key,
166
+ row: {
167
+ authorId: demoUserId(p.author),
168
+ imageUrl: postImagePath(p.key),
169
+ caption: p.caption,
170
+ shape: p.shape,
171
+ createdAt: hoursAgo(p.age),
172
+ },
173
+ })),
174
+ likes: SEED_POSTS.flatMap((p) =>
175
+ likersOf(p).map((u, i) => ({
176
+ post: p.key,
177
+ row: { userId: demoUserId(u), createdAt: hoursAgo(Math.max(0.1, p.age - 0.5 - i * 0.7)) },
178
+ })),
179
+ ),
180
+ comments: SEED_COMMENTS.map((c) => ({
181
+ post: c.post,
182
+ row: {
183
+ authorId: demoUserId(c.author),
184
+ text: c.text,
185
+ createdAt: hoursAgo(Math.max(0.05, (postAge.get(c.post) ?? 0) - c.after)),
186
+ },
187
+ })),
188
+ follows: Object.entries(SEED_FOLLOWS).flatMap(([from, tos], i) =>
189
+ tos.map((to) => ({ followerId: demoUserId(from), followingId: demoUserId(to), createdAt: hoursAgo(1500 - i * 30) })),
190
+ ),
191
+ };
192
+ }
@@ -0,0 +1,6 @@
1
+ // The app's name as people see it: the wordmark, page titles, and llms.txt.
2
+ // Rename it here.
3
+ export const SITE = {
4
+ name: "Glimpse",
5
+ description: "Share photos with the people you follow. Likes, comments, and follows update live.",
6
+ };