@pylonsync/create-pylon 0.11.1 → 0.11.4
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 +1 -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/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/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
|
@@ -147,6 +147,24 @@ const manifest = buildManifest({
|
|
|
147
147
|
display: "swap",
|
|
148
148
|
preload: true,
|
|
149
149
|
}),
|
|
150
|
+
// Headlines and the product mock's window title.
|
|
151
|
+
font({
|
|
152
|
+
family: "Space Grotesk",
|
|
153
|
+
variable: "--font-display",
|
|
154
|
+
weights: ["500", "700"],
|
|
155
|
+
subsets: ["latin"],
|
|
156
|
+
display: "swap",
|
|
157
|
+
preload: true,
|
|
158
|
+
}),
|
|
159
|
+
// Small details: the launch date, the live count, the form hint.
|
|
160
|
+
font({
|
|
161
|
+
family: "JetBrains Mono",
|
|
162
|
+
variable: "--font-mono-ui",
|
|
163
|
+
weights: ["400", "500"],
|
|
164
|
+
subsets: ["latin"],
|
|
165
|
+
display: "swap",
|
|
166
|
+
preload: false,
|
|
167
|
+
}),
|
|
150
168
|
],
|
|
151
169
|
routes: await discoverAppRoutes(),
|
|
152
170
|
});
|
|
@@ -1,96 +1,82 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import type { Fact, MockRow } from "@/lib/site.config";
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
|
|
4
|
+
// Presentational pieces for the landing page. All server-rendered, no client
|
|
5
|
+
// JS. Colors come from CSS vars set on <html> in app/layout.tsx, which read
|
|
6
|
+
// lib/site.config.ts.
|
|
7
7
|
|
|
8
|
-
// Shared container: a
|
|
9
|
-
export const WRAP = "mx-auto w-full max-w-
|
|
8
|
+
// Shared container: the page is left-aligned inside a wide column.
|
|
9
|
+
export const WRAP = "mx-auto w-full max-w-6xl px-6";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Three short facts in a row, a thin rule above each.
|
|
12
|
+
export function FactList({ items }: { items: Fact[] }) {
|
|
12
13
|
return (
|
|
13
|
-
<
|
|
14
|
-
{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
|
|
23
|
-
<span className="inline-block size-1.5 rounded-full bg-brand" />
|
|
24
|
-
{children}
|
|
25
|
-
</span>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function Divider() {
|
|
30
|
-
return (
|
|
31
|
-
<div className={WRAP}>
|
|
32
|
-
<div className="border-t border-zinc-200/70" />
|
|
33
|
-
</div>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function SectionHead({
|
|
38
|
-
eyebrow,
|
|
39
|
-
title,
|
|
40
|
-
body,
|
|
41
|
-
}: {
|
|
42
|
-
eyebrow: string;
|
|
43
|
-
title: string;
|
|
44
|
-
body?: string;
|
|
45
|
-
}) {
|
|
46
|
-
return (
|
|
47
|
-
<div>
|
|
48
|
-
<Eyebrow>{eyebrow}</Eyebrow>
|
|
49
|
-
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
50
|
-
{title}
|
|
51
|
-
</h2>
|
|
52
|
-
{body ? (
|
|
53
|
-
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
54
|
-
{body}
|
|
55
|
-
</p>
|
|
56
|
-
) : null}
|
|
57
|
-
</div>
|
|
14
|
+
<ul className="grid gap-x-8 gap-y-10 sm:grid-cols-3">
|
|
15
|
+
{items.map((f) => (
|
|
16
|
+
<li key={f.title} className="border-t border-line pt-5">
|
|
17
|
+
<h3 className="font-display text-[17px] font-medium text-chalk">{f.title}</h3>
|
|
18
|
+
<p className="mt-2 text-[14px] leading-relaxed text-chalk-2">{f.body}</p>
|
|
19
|
+
</li>
|
|
20
|
+
))}
|
|
21
|
+
</ul>
|
|
58
22
|
);
|
|
59
23
|
}
|
|
60
24
|
|
|
61
|
-
// A
|
|
62
|
-
|
|
63
|
-
|
|
25
|
+
// A product window drawn with divs: a title bar, a sidebar, and task rows.
|
|
26
|
+
// No screenshot. Content comes from `siteConfig.mock`.
|
|
27
|
+
export function ProductMock({
|
|
28
|
+
windowTitle,
|
|
29
|
+
sidebar,
|
|
30
|
+
activeSidebarIndex,
|
|
31
|
+
rows,
|
|
64
32
|
}: {
|
|
65
|
-
|
|
33
|
+
windowTitle: string;
|
|
34
|
+
sidebar: string[];
|
|
35
|
+
activeSidebarIndex: number;
|
|
36
|
+
rows: MockRow[];
|
|
66
37
|
}) {
|
|
67
38
|
return (
|
|
68
|
-
<div
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
39
|
+
<div
|
|
40
|
+
aria-hidden
|
|
41
|
+
className="overflow-hidden rounded-lg border border-line bg-paper text-[12px] text-chalk-2"
|
|
42
|
+
>
|
|
43
|
+
<div className="flex h-9 items-center gap-2 border-b border-line px-3">
|
|
44
|
+
<span className="size-2.5 rounded-full bg-line" />
|
|
45
|
+
<span className="size-2.5 rounded-full bg-line" />
|
|
46
|
+
<span className="size-2.5 rounded-full bg-line" />
|
|
47
|
+
<span className="ml-3 font-display text-[12px] font-medium text-chalk">{windowTitle}</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="grid grid-cols-[112px_1fr] sm:grid-cols-[140px_1fr]">
|
|
50
|
+
<div className="border-r border-line py-2">
|
|
51
|
+
{sidebar.map((item, i) => (
|
|
52
|
+
<div
|
|
53
|
+
key={item}
|
|
54
|
+
className={
|
|
55
|
+
i === activeSidebarIndex
|
|
56
|
+
? "mx-2 rounded-md bg-ink-2 px-2 py-1.5 text-chalk"
|
|
57
|
+
: "mx-2 px-2 py-1.5"
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{item}
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
82
63
|
</div>
|
|
83
|
-
|
|
64
|
+
<div className="divide-y divide-line">
|
|
65
|
+
{rows.map((r) => (
|
|
66
|
+
<div key={r.title} className="flex items-center gap-3 px-3 py-2.5">
|
|
67
|
+
<span
|
|
68
|
+
className={
|
|
69
|
+
r.done
|
|
70
|
+
? "size-3.5 shrink-0 rounded-sm bg-brand"
|
|
71
|
+
: "size-3.5 shrink-0 rounded-sm border border-chalk-2/60"
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
<span className={r.done ? "text-chalk-2 line-through" : "text-chalk"}>{r.title}</span>
|
|
75
|
+
<span className="ml-auto font-mono-ui text-[11px] text-chalk-2">{r.tag}</span>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
84
80
|
</div>
|
|
85
81
|
);
|
|
86
82
|
}
|
|
87
|
-
|
|
88
|
-
// Initials for testimonial avatars, so the cards look finished without a photo.
|
|
89
|
-
export function initials(name: string) {
|
|
90
|
-
return name
|
|
91
|
-
.split(/\s+/)
|
|
92
|
-
.map((w) => w[0])
|
|
93
|
-
.join("")
|
|
94
|
-
.slice(0, 2)
|
|
95
|
-
.toUpperCase();
|
|
96
|
-
}
|
|
@@ -22,21 +22,26 @@ export type BaseConfig = {
|
|
|
22
22
|
socials: Social[];
|
|
23
23
|
};
|
|
24
24
|
// Marketing accent + surfaces. Applied as CSS vars on <html> in layout.tsx.
|
|
25
|
-
|
|
25
|
+
// `ink` is the page background, `paper` the raised panel color, `brand` the
|
|
26
|
+
// one accent, `brandSoft` the accent's dim fill.
|
|
27
|
+
colors: { brand: string; brandSoft: string; paper: string; ink: string };
|
|
26
28
|
seo: { title: string; description: string };
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
export type
|
|
30
|
-
export type Quote = { quote: string; name: string; role: string };
|
|
31
|
+
export type Fact = { title: string; body: string };
|
|
31
32
|
export type Faq = { q: string; a: string };
|
|
32
33
|
|
|
34
|
+
// A row in the product mock (a task or a note), drawn with plain divs.
|
|
35
|
+
export type MockRow = { title: string; tag: string; done?: boolean };
|
|
36
|
+
|
|
33
37
|
export type WaitlistConfig = BaseConfig & {
|
|
34
38
|
hero: {
|
|
35
|
-
|
|
39
|
+
launchNote: string; // one short line, set in mono: when it ships
|
|
36
40
|
headline: string;
|
|
37
41
|
subcopy: string;
|
|
38
42
|
emailPlaceholder: string;
|
|
39
43
|
ctaLabel: string;
|
|
44
|
+
formHint: string;
|
|
40
45
|
successMessage: string;
|
|
41
46
|
};
|
|
42
47
|
counter: {
|
|
@@ -46,13 +51,15 @@ export type WaitlistConfig = BaseConfig & {
|
|
|
46
51
|
// page doesn't read "0". Set to 0 to show only genuine signups.
|
|
47
52
|
seedCount?: number;
|
|
48
53
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// The HTML/CSS product window on the right of the hero. No screenshot.
|
|
55
|
+
mock: {
|
|
56
|
+
windowTitle: string;
|
|
57
|
+
sidebar: string[];
|
|
58
|
+
activeSidebarIndex: number;
|
|
59
|
+
rows: MockRow[];
|
|
54
60
|
};
|
|
55
|
-
|
|
61
|
+
facts: { headline: string; items: Fact[] };
|
|
62
|
+
faq?: { headline: string; items: Faq[] };
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
/* ----------------------------- config ---------------------------- */
|
|
@@ -64,7 +71,7 @@ export const siteConfig: WaitlistConfig = {
|
|
|
64
71
|
domain: "lumo.app",
|
|
65
72
|
email: "hello@lumo.example",
|
|
66
73
|
footerBlurb:
|
|
67
|
-
"Lumo keeps projects, notes, and tasks in one
|
|
74
|
+
"Lumo keeps projects, notes, and tasks in one workspace. Join the list for an early invite.",
|
|
68
75
|
copyrightName: "Lumo, Inc.",
|
|
69
76
|
socials: [
|
|
70
77
|
{
|
|
@@ -80,95 +87,81 @@ export const siteConfig: WaitlistConfig = {
|
|
|
80
87
|
],
|
|
81
88
|
},
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
// Near-black page, one electric blue accent.
|
|
91
|
+
colors: { brand: "#4f6bff", brandSoft: "#141a3d", paper: "#121214", ink: "#0b0b0c" },
|
|
84
92
|
|
|
85
93
|
seo: {
|
|
86
|
-
title: "Lumo —
|
|
94
|
+
title: "Lumo — projects, notes, and tasks in one workspace. Coming soon.",
|
|
87
95
|
description:
|
|
88
|
-
"Lumo
|
|
96
|
+
"Lumo puts your projects, notes, and tasks in one workspace. It launches this fall. Join the waitlist for an early invite.",
|
|
89
97
|
},
|
|
90
98
|
|
|
91
99
|
hero: {
|
|
92
|
-
|
|
93
|
-
headline: "Projects, notes, and tasks in one
|
|
100
|
+
launchNote: "Launch: fall 2026",
|
|
101
|
+
headline: "Projects, notes, and tasks in one workspace.",
|
|
94
102
|
subcopy:
|
|
95
|
-
"Lumo
|
|
103
|
+
"Lumo is one app for the plan, the notes behind it, and the tasks that come out of it. Join the list and we send an invite before the public launch.",
|
|
96
104
|
emailPlaceholder: "you@work.com",
|
|
97
105
|
ctaLabel: "Join the waitlist",
|
|
98
|
-
|
|
106
|
+
formHint: "One invite email. No newsletter.",
|
|
107
|
+
successMessage: "You are on the list. We will email your invite.",
|
|
99
108
|
},
|
|
100
109
|
|
|
101
110
|
counter: {
|
|
102
111
|
enabled: true,
|
|
103
|
-
label: "people
|
|
112
|
+
label: "people on the list",
|
|
104
113
|
seedCount: 1200,
|
|
105
114
|
},
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
icon: "◎",
|
|
118
|
-
title: "For people who make things",
|
|
119
|
-
body: "Designers, founders, writers, builders. If your work needs deep focus, Lumo is built for you.",
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
icon: "✦",
|
|
123
|
-
title: "Calm by default",
|
|
124
|
-
body: "Lumo keeps notifications and feeds quiet, then shows the work that needs attention now.",
|
|
125
|
-
},
|
|
116
|
+
mock: {
|
|
117
|
+
windowTitle: "Site redesign",
|
|
118
|
+
sidebar: ["Inbox", "Site redesign", "Q4 plan", "Reading", "Archive"],
|
|
119
|
+
activeSidebarIndex: 1,
|
|
120
|
+
rows: [
|
|
121
|
+
{ title: "Draft the homepage copy", tag: "Today", done: true },
|
|
122
|
+
{ title: "Review nav on mobile", tag: "Today" },
|
|
123
|
+
{ title: "Pick the display face", tag: "Note" },
|
|
124
|
+
{ title: "Send pricing page to Priya", tag: "Tomorrow" },
|
|
125
|
+
{ title: "Set up the launch checklist", tag: "Next week" },
|
|
126
126
|
],
|
|
127
127
|
},
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
facts: {
|
|
130
|
+
headline: "What Lumo does",
|
|
131
|
+
items: [
|
|
132
132
|
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
name: "Maya Chen",
|
|
136
|
-
role: "Product designer",
|
|
133
|
+
title: "One place for a project",
|
|
134
|
+
body: "Each project holds its plan, its notes, and its tasks. Nothing lives in a second app.",
|
|
137
135
|
},
|
|
138
136
|
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
name: "Daniel Reyes",
|
|
142
|
-
role: "Indie founder",
|
|
137
|
+
title: "Notes turn into tasks",
|
|
138
|
+
body: "Select a line in a note and make it a task. The task keeps a link back to the note.",
|
|
143
139
|
},
|
|
144
140
|
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
name: "Hannah Brooks",
|
|
148
|
-
role: "Writer",
|
|
141
|
+
title: "No feed, no badges",
|
|
142
|
+
body: "Lumo has no activity feed. You see the tasks due today and the project you opened last.",
|
|
149
143
|
},
|
|
150
144
|
],
|
|
151
145
|
},
|
|
152
146
|
|
|
153
147
|
faq: {
|
|
154
|
-
|
|
155
|
-
headline: "Good to know.",
|
|
148
|
+
headline: "Questions",
|
|
156
149
|
items: [
|
|
157
150
|
{
|
|
158
151
|
q: "When does Lumo launch?",
|
|
159
|
-
a: "
|
|
152
|
+
a: "Fall 2026. People on the waitlist get an invite before the public launch.",
|
|
160
153
|
},
|
|
161
154
|
{
|
|
162
155
|
q: "What do I get for joining?",
|
|
163
|
-
a: "An early invite
|
|
156
|
+
a: "An early invite and founding-member pricing on paid plans.",
|
|
164
157
|
},
|
|
165
158
|
{
|
|
166
159
|
q: "How much will it cost?",
|
|
167
|
-
a: "There
|
|
160
|
+
a: "There is a free tier. Paid plans are priced at launch. Waitlist members keep the founding-member price.",
|
|
168
161
|
},
|
|
169
162
|
{
|
|
170
163
|
q: "Will my email be shared?",
|
|
171
|
-
a: "
|
|
164
|
+
a: "No. We use it for the invite and launch updates only. You can unsubscribe at any time.",
|
|
172
165
|
},
|
|
173
166
|
],
|
|
174
167
|
},
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useEffect, useState } from "react";
|
|
4
|
-
import { db, callFn } from "@pylonsync/react";
|
|
5
|
-
import { EnsureGuest, useAuth } from "@pylonsync/client";
|
|
6
|
-
import { Button } from "@/components/ui/button";
|
|
7
|
-
|
|
8
|
-
export interface Post {
|
|
9
|
-
id: string;
|
|
10
|
-
authorId: string;
|
|
11
|
-
text: string;
|
|
12
|
-
createdAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface Like {
|
|
16
|
-
id: string;
|
|
17
|
-
userId: string;
|
|
18
|
-
postId: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// `<EnsureGuest>` mints a guest session so anyone can post + like with no
|
|
22
|
-
// login. Inside it, `useAuth()` exposes the guest's `userId` — that's how we
|
|
23
|
-
// know which posts the current visitor has already liked.
|
|
24
|
-
export function Feed() {
|
|
25
|
-
return (
|
|
26
|
-
<EnsureGuest fallback={<FeedSkeleton />}>
|
|
27
|
-
<FeedInner />
|
|
28
|
-
</EnsureGuest>
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function FeedInner() {
|
|
33
|
-
const { userId } = useAuth();
|
|
34
|
-
const [text, setText] = useState("");
|
|
35
|
-
// Seed a few demo posts on first visit so the feed isn't empty. No-op once
|
|
36
|
-
// any post exists; safe to call every mount.
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
void callFn("seedPosts", {});
|
|
39
|
-
}, []);
|
|
40
|
-
const { data: posts, loading } = db.useQuery<Post>("Post");
|
|
41
|
-
// Public-read, so this is every like in the system — we count + match them
|
|
42
|
-
// client-side. db.useQuery is live, so counts update the instant anyone likes.
|
|
43
|
-
const { data: likes } = db.useQuery<Like>("Like");
|
|
44
|
-
|
|
45
|
-
async function addPost(e: React.FormEvent) {
|
|
46
|
-
e.preventDefault();
|
|
47
|
-
const value = text.trim();
|
|
48
|
-
if (!value) return;
|
|
49
|
-
setText("");
|
|
50
|
-
// No authorId sent — field.owner() stamps it from the session.
|
|
51
|
-
await db.insert("Post", { text: value });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function toggleLike(postId: string) {
|
|
55
|
-
const mine = likes.find((l) => l.postId === postId && l.userId === userId);
|
|
56
|
-
if (mine) {
|
|
57
|
-
void db.delete("Like", mine.id);
|
|
58
|
-
} else {
|
|
59
|
-
void db.insert("Like", { postId });
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const ordered = [...posts].sort((a, b) =>
|
|
64
|
-
b.createdAt.localeCompare(a.createdAt),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<div className="space-y-6">
|
|
69
|
-
<form onSubmit={addPost} className="space-y-2">
|
|
70
|
-
<textarea
|
|
71
|
-
value={text}
|
|
72
|
-
onChange={(e) => setText(e.target.value)}
|
|
73
|
-
placeholder="What's happening?"
|
|
74
|
-
aria-label="Post"
|
|
75
|
-
rows={2}
|
|
76
|
-
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
77
|
-
/>
|
|
78
|
-
<div className="flex justify-end">
|
|
79
|
-
<Button type="submit" disabled={!text.trim()}>
|
|
80
|
-
Post
|
|
81
|
-
</Button>
|
|
82
|
-
</div>
|
|
83
|
-
</form>
|
|
84
|
-
|
|
85
|
-
{loading && posts.length === 0 ? (
|
|
86
|
-
<FeedSkeleton />
|
|
87
|
-
) : ordered.length === 0 ? (
|
|
88
|
-
<p className="text-sm text-muted-foreground">
|
|
89
|
-
No posts yet — say something above. It appears instantly and syncs;
|
|
90
|
-
open a second tab to watch the feed update live.
|
|
91
|
-
</p>
|
|
92
|
-
) : (
|
|
93
|
-
<ul className="space-y-3">
|
|
94
|
-
{ordered.map((post) => {
|
|
95
|
-
const count = likes.filter((l) => l.postId === post.id).length;
|
|
96
|
-
const liked = likes.some(
|
|
97
|
-
(l) => l.postId === post.id && l.userId === userId,
|
|
98
|
-
);
|
|
99
|
-
const mine = post.authorId === userId;
|
|
100
|
-
return (
|
|
101
|
-
<li key={post.id} className="rounded-lg border px-4 py-3">
|
|
102
|
-
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
103
|
-
<span className="font-mono">
|
|
104
|
-
{mine ? "you" : shortId(post.authorId)}
|
|
105
|
-
</span>
|
|
106
|
-
{mine && (
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
aria-label="Delete post"
|
|
110
|
-
onClick={() => db.delete("Post", post.id)}
|
|
111
|
-
className="hover:text-red-600"
|
|
112
|
-
>
|
|
113
|
-
Delete
|
|
114
|
-
</button>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
<p className="mt-1 whitespace-pre-wrap text-sm">{post.text}</p>
|
|
118
|
-
<div className="mt-2">
|
|
119
|
-
<button
|
|
120
|
-
type="button"
|
|
121
|
-
onClick={() => toggleLike(post.id)}
|
|
122
|
-
aria-pressed={liked}
|
|
123
|
-
className={
|
|
124
|
-
"inline-flex items-center gap-1.5 text-xs transition-colors " +
|
|
125
|
-
(liked
|
|
126
|
-
? "text-rose-600"
|
|
127
|
-
: "text-muted-foreground hover:text-foreground")
|
|
128
|
-
}
|
|
129
|
-
>
|
|
130
|
-
<span>{liked ? "♥" : "♡"}</span>
|
|
131
|
-
<span>{count}</span>
|
|
132
|
-
</button>
|
|
133
|
-
</div>
|
|
134
|
-
</li>
|
|
135
|
-
);
|
|
136
|
-
})}
|
|
137
|
-
</ul>
|
|
138
|
-
)}
|
|
139
|
-
</div>
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Guest ids look like `guest_<hex>`; show a short, stable handle.
|
|
144
|
-
function shortId(id: string) {
|
|
145
|
-
return "@" + id.replace(/^guest_/, "").slice(0, 6);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function FeedSkeleton() {
|
|
149
|
-
return (
|
|
150
|
-
<ul className="space-y-3" aria-hidden>
|
|
151
|
-
{[0, 1, 2].map((i) => (
|
|
152
|
-
<li key={i} className="rounded-lg border px-4 py-3">
|
|
153
|
-
<span className="block h-3 w-16 rounded bg-muted" />
|
|
154
|
-
<span className="mt-2 block h-4 w-3/4 rounded bg-muted" />
|
|
155
|
-
</li>
|
|
156
|
-
))}
|
|
157
|
-
</ul>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { type Metadata } from "@pylonsync/react";
|
|
3
|
-
import { Feed } from "./feed-client";
|
|
4
|
-
|
|
5
|
-
export const metadata: Metadata = {
|
|
6
|
-
title: "__APP_NAME__ — a live social feed on Pylon",
|
|
7
|
-
description:
|
|
8
|
-
"A public feed with optimistic posts and likes, server-rendered over one Pylon backend. Open two tabs and watch it sync.",
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
// `app/page.tsx` → `/`. The intro is server-rendered; `<Feed>` is a client
|
|
12
|
-
// island that mints a guest session and runs the live query + optimistic
|
|
13
|
-
// posts/likes in the browser.
|
|
14
|
-
export default function IndexPage() {
|
|
15
|
-
return (
|
|
16
|
-
<div>
|
|
17
|
-
<header className="mb-6">
|
|
18
|
-
<h1 className="text-2xl font-semibold tracking-tight">Feed</h1>
|
|
19
|
-
<p className="mt-1 text-sm text-muted-foreground">
|
|
20
|
-
Post something — it appears instantly and syncs to every tab. Likes
|
|
21
|
-
are live too.
|
|
22
|
-
</p>
|
|
23
|
-
</header>
|
|
24
|
-
<Feed />
|
|
25
|
-
</div>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// Server functions go here. Each file in this directory that exports a
|
|
2
|
-
// query() or action() becomes a typed RPC endpoint, callable from your
|
|
3
|
-
// pages and client with full type inference. Delete this placeholder when
|
|
4
|
-
// you add your first one.
|
|
5
|
-
//
|
|
6
|
-
// Example (functions/notes.ts):
|
|
7
|
-
//
|
|
8
|
-
// import { query } from "@pylonsync/functions";
|
|
9
|
-
//
|
|
10
|
-
// export const listNotes = query(async (ctx) => {
|
|
11
|
-
// return ctx.db.list("Note");
|
|
12
|
-
// });
|
|
13
|
-
export {};
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { mutation } from "@pylonsync/functions";
|
|
2
|
-
|
|
3
|
-
// A few demo posts so the feed isn't an empty shell on first visit. The feed
|
|
4
|
-
// calls this on mount; it's a no-op once any post exists (the lock guards
|
|
5
|
-
// against a double-seed from two concurrent first-visits). Public so an
|
|
6
|
-
// anonymous first visitor seeds it — it only writes demo content.
|
|
7
|
-
//
|
|
8
|
-
// `unsafe.insert` sets the demo `authorId` + a backdated `createdAt` directly
|
|
9
|
-
// (bypassing the `field.owner()` stamp + policies) so the seed reads like a few
|
|
10
|
-
// different people already posted, instead of everything attributed to the
|
|
11
|
-
// first visitor.
|
|
12
|
-
const DEMO_POSTS: { author: string; text: string }[] = [
|
|
13
|
-
{
|
|
14
|
-
author: "guest_ada",
|
|
15
|
-
text: "just shipped my first Pylon app — one binary doing SSR + sync + auth. wild.",
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
author: "guest_lin",
|
|
19
|
-
text: "the feed updates across tabs with zero websocket code I wrote. open a second tab 👀",
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
author: "guest_rey",
|
|
23
|
-
text: "likes are just rows — delete the row to unlike. local-first, so it's instant.",
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
author: "guest_max",
|
|
27
|
-
text: "no separate backend to deploy. `pylon deploy` and it's live. that's the whole thing.",
|
|
28
|
-
},
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
export default mutation<
|
|
32
|
-
Record<string, never>,
|
|
33
|
-
{ seeded: boolean; count: number }
|
|
34
|
-
>({
|
|
35
|
-
auth: "public",
|
|
36
|
-
async handler(ctx) {
|
|
37
|
-
await ctx.db.advisoryLock("consumer_seed_posts");
|
|
38
|
-
const existing = await ctx.db.unsafe.list("Post");
|
|
39
|
-
if (existing.length > 0) return { seeded: false, count: existing.length };
|
|
40
|
-
|
|
41
|
-
const now = Date.now();
|
|
42
|
-
for (let i = 0; i < DEMO_POSTS.length; i++) {
|
|
43
|
-
const p = DEMO_POSTS[i];
|
|
44
|
-
// Backdate a minute apart so they sort into a natural order.
|
|
45
|
-
const createdAt = new Date(
|
|
46
|
-
now - (DEMO_POSTS.length - i) * 60_000,
|
|
47
|
-
).toISOString();
|
|
48
|
-
await ctx.db.unsafe.insert("Post", {
|
|
49
|
-
authorId: p.author,
|
|
50
|
-
text: p.text,
|
|
51
|
-
createdAt,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
return { seeded: true, count: DEMO_POSTS.length };
|
|
55
|
-
},
|
|
56
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
// Your first test. Run the suite with: pylon test (or: npm test)
|
|
4
|
-
//
|
|
5
|
-
// `pylon test` discovers every *.test.ts file under tests/ (or functions/) and
|
|
6
|
-
// runs it with Bun's test runner against an in-memory Pylon. Replace this
|
|
7
|
-
// placeholder with tests for your functions and helpers — see AGENTS.md →
|
|
8
|
-
// Testing for the pure-logic and HTTP-against-`pylon dev` patterns.
|
|
9
|
-
|
|
10
|
-
test("placeholder — replace me with a real test", () => {
|
|
11
|
-
expect(true).toBe(true);
|
|
12
|
-
});
|