@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.
Files changed (154) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +31 -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/expo/mobile/apps/expo/README.md +1 -1
  91. package/templates/expo/mobile/apps/expo/package.json +55 -54
  92. package/templates/expo/mobile/apps/expo/src/entitlements.ts +24 -6
  93. package/templates/helpdesk/AGENTS.md +1 -1
  94. package/templates/helpdesk/lib/seed.ts +188 -104
  95. package/templates/inventory/AGENTS.md +1 -1
  96. package/templates/inventory/lib/seed.ts +175 -44
  97. package/templates/invoices/AGENTS.md +1 -1
  98. package/templates/invoices/lib/seed.ts +74 -89
  99. package/templates/local-service/AGENTS.md +1 -1
  100. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  101. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  102. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  103. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  104. package/templates/local-service/app/globals.css +11 -0
  105. package/templates/local-service/app/layout.tsx +2 -0
  106. package/templates/local-service/app.ts +9 -0
  107. package/templates/local-service/components/marketing.tsx +14 -39
  108. package/templates/local-service/lib/site.config.ts +16 -23
  109. package/templates/local-service/public/images/hero.jpg +0 -0
  110. package/templates/marketplace/AGENTS.md +1 -1
  111. package/templates/marketplace/client/SellForm.tsx +2 -0
  112. package/templates/projects/AGENTS.md +1 -1
  113. package/templates/projects/lib/seed.ts +103 -32
  114. package/templates/restaurant/AGENTS.md +1 -1
  115. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  116. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  117. package/templates/restaurant/app/globals.css +11 -0
  118. package/templates/restaurant/app/layout.tsx +1 -0
  119. package/templates/restaurant/app.ts +10 -0
  120. package/templates/restaurant/components/marketing.tsx +10 -0
  121. package/templates/restaurant/lib/site.config.ts +16 -14
  122. package/templates/restaurant/public/images/bar.jpg +0 -0
  123. package/templates/restaurant/public/images/branzino.jpg +0 -0
  124. package/templates/restaurant/public/images/hearth.jpg +0 -0
  125. package/templates/restaurant/public/images/hero.jpg +0 -0
  126. package/templates/saas/AGENTS.md +1 -1
  127. package/templates/shop/AGENTS.md +1 -1
  128. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  129. package/templates/shop/public/images/products/linen.jpg +0 -0
  130. package/templates/todo/AGENTS.md +1 -1
  131. package/templates/waitlist/AGENTS.md +1 -1
  132. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  133. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  134. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  135. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  136. package/templates/waitlist/app/globals.css +23 -0
  137. package/templates/waitlist/app/layout.tsx +1 -0
  138. package/templates/waitlist/app.ts +18 -0
  139. package/templates/waitlist/components/marketing.tsx +67 -81
  140. package/templates/waitlist/lib/site.config.ts +52 -59
  141. package/templates/agency/app/not-found.tsx +0 -44
  142. package/templates/ai-chat/app/not-found.tsx +0 -44
  143. package/templates/ai-studio/app/not-found.tsx +0 -44
  144. package/templates/consumer/app/feed-client.tsx +0 -159
  145. package/templates/consumer/app/page.tsx +0 -27
  146. package/templates/consumer/functions/_keep.ts +0 -13
  147. package/templates/consumer/functions/seedPosts.ts +0 -56
  148. package/templates/consumer/tests/example.test.ts +0 -12
  149. package/templates/creator/app/not-found.tsx +0 -44
  150. package/templates/directory/app/not-found.tsx +0 -44
  151. package/templates/local-service/app/not-found.tsx +0 -44
  152. package/templates/restaurant/app/not-found.tsx +0 -44
  153. package/templates/shop/app/not-found.tsx +0 -44
  154. package/templates/waitlist/app/not-found.tsx +0 -44
@@ -1,7 +1,8 @@
1
1
  # __APP_NAME__
2
2
 
3
- A live social feed on [Pylon](https://pylonsync.com) with a public timeline,
4
- optimistic posts, and likes over one synced backend.
3
+ A photo-sharing app on [Pylon](https://pylonsync.com): a home feed, an
4
+ explore grid, post pages with comments, and profiles with followers and
5
+ saved posts. Likes, comments, and follows update live in every open tab.
5
6
 
6
7
  ## Develop
7
8
 
@@ -9,38 +10,53 @@ optimistic posts, and likes over one synced backend.
9
10
  __RUN_DEV__
10
11
  ```
11
12
 
12
- Open http://localhost:4321 and post something. It appears optimistically and
13
- syncs to a second tab along with like counts. Editing a file under `app/`
14
- reloads the page.
13
+ Open http://localhost:4321. The first visit loads twelve demo people with
14
+ their posts, likes, comments, and follows, and gives you a guest profile.
15
+ Post a photo with Create, like with a double tap, and open a second tab to
16
+ watch counts change.
15
17
 
16
18
  ## Layout
17
19
 
18
20
  ```
19
- app.ts data model: Post + Like entities, feed policies, auth
20
- app/page.tsx "/" — the server-rendered page
21
- app/feed-client.tsx client island: guest session + live feed, posts, likes
22
- app/layout.tsx root layout wrapping every page
23
- app/globals.css Tailwind entrypoint (compiled by Pylon)
21
+ app.ts data model, policies, fonts
22
+ app/(app)/layout.tsx navigation around every page
23
+ app/(app)/page.tsx / home feed
24
+ app/(app)/explore/page.tsx /explore every post in a grid
25
+ app/(app)/p/[id]/page.tsx /p/:id one post and its comments
26
+ app/(app)/u/[username]/page.tsx /u/:username a profile
27
+ components/social/ the client islands for those pages
28
+ lib/social.ts usernames, counts, feed rules (pure, tested)
29
+ lib/seed.ts the demo people and posts
30
+ lib/site.ts the app's name
31
+ functions/ ensureProfile, updateProfile, createPost,
32
+ addComment, seedFeed
33
+ public/images/ demo avatars and photos
24
34
  ```
25
35
 
26
36
  ## How it works
27
37
 
28
- No login wall: `app/feed-client.tsx` wraps the feed in `<EnsureGuest>`, which
29
- mints a guest session so every visitor can post + like. The feed is
30
- **public-read** (everyone sees every post and like count — intentional for a
31
- feed), while writes are **owner-only**: `authorId`/`userId: field.owner()`
32
- stamp the session's id server-side, so an optimistic `db.insert` can't forge
33
- authorship. A like is a join row (one per user per post); the count is just how
34
- many `Like` rows point at a post, and `db.useQuery` keeps it live.
38
+ - **Sessions.** `components/social/session.tsx` wraps each island in
39
+ `<EnsureGuest>`, so every visitor gets a session without a sign-up form,
40
+ then calls `ensureProfile` to give them a username.
41
+ - **Reads are public, writes are yours.** Every entity is readable by
42
+ anyone except `Save`, which only its owner can see. A like, follow, or
43
+ save must carry the caller's own id; the policies in `app.ts` refuse any
44
+ other. Posts, comments, and profile edits go through functions, which
45
+ check image URLs, caption length, and username rules on the server.
46
+ - **Photos.** Create uploads the file with
47
+ `uploadFile(file, { visibility: "public" })` from `@pylonsync/react`, so
48
+ every visitor can load it, then calls `createPost` with its URL.
49
+ - **Counts.** A like count is how many `Like` rows point at a post. The
50
+ queries are live, so a count changes the moment someone taps.
35
51
 
36
52
  ## Grow it
37
53
 
38
- - **Profiles:** add a `Profile` entity (displayName/avatar keyed by `userId`)
39
- to show names instead of `@guest…` handles.
40
- - **Follows:** add a `Follow` join entity (`followerId`/`followedId`) and
41
- filter the feed to people you follow.
42
- - **Real accounts:** email/password is built in — swap `<EnsureGuest>` for
43
- `<SignedIn>` / `<SignedOut>` from `@pylonsync/client`.
54
+ - **Real accounts:** email and password are built in. Add a sign-in page and
55
+ move a guest's profile to the account on sign-in.
56
+ - **Scale:** `components/social/use-social.ts` reads every row, which is fine
57
+ for a demo. For a large network, query per screen with `where` and `limit`.
58
+ - **Launch:** delete `functions/seedFeed.ts`, `lib/seed.ts`, and
59
+ `public/images/`, and remove the `seedFeed` call in `session.tsx`.
44
60
 
45
61
  ## Deploy
46
62
 
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { type Metadata } from "@pylonsync/react";
3
+ import { ExploreScreen } from "@/components/social/explore-screen";
4
+ import { SITE } from "@/lib/site";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Explore · ${SITE.name}`,
8
+ };
9
+
10
+ // `app/(app)/explore/page.tsx` → `/explore`. Every post, newest first.
11
+ export default function ExplorePage() {
12
+ return <ExploreScreen />;
13
+ }
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { AppChrome } from "@/components/social/app-chrome";
3
+
4
+ interface LayoutProps {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ // `(app)` is a route group: the parens segment is not part of any URL. This
9
+ // layout adds the navigation around every page of the app.
10
+ export default function AppLayout({ children }: LayoutProps) {
11
+ return (
12
+ <>
13
+ <AppChrome />
14
+ <main className="pb-14 md:pb-0 md:pl-[72px] xl:pl-[244px]">{children}</main>
15
+ </>
16
+ );
17
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { PostScreen } from "@/components/social/post-screen";
4
+ import { SITE } from "@/lib/site";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Post · ${SITE.name}`,
8
+ };
9
+
10
+ // `app/(app)/p/[id]/page.tsx` → `/p/:id`. One post with all its comments.
11
+ export default function PostPage({ params }: PageProps<{ id: string }>) {
12
+ return <PostScreen postId={params.id} />;
13
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { type Metadata } from "@pylonsync/react";
3
+ import { HomeScreen } from "@/components/social/home-screen";
4
+ import { SITE } from "@/lib/site";
5
+
6
+ export const metadata: Metadata = {
7
+ title: SITE.name,
8
+ description: SITE.description,
9
+ };
10
+
11
+ // `app/(app)/page.tsx` → `/`. The feed is a client island: it needs the
12
+ // visitor's session to know who they follow and what they liked.
13
+ export default function HomePage() {
14
+ return <HomeScreen />;
15
+ }
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { ProfileScreen } from "@/components/social/profile-screen";
4
+ import { SITE } from "@/lib/site";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Profile · ${SITE.name}`,
8
+ };
9
+
10
+ // `app/(app)/u/[username]/page.tsx` → `/u/:username`. A person's posts,
11
+ // followers, and following. Your own profile also shows your saved posts.
12
+ export default function ProfilePage({ params }: PageProps<{ username: string }>) {
13
+ return <ProfileScreen username={params.username} />;
14
+ }
@@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button";
11
11
  export default function Error({ error, reset }: ErrorBoundaryProps) {
12
12
  const [tries, setTries] = React.useState(0);
13
13
  return (
14
- <div className="space-y-6">
14
+ <div className="mx-auto max-w-md space-y-6 px-6 py-24">
15
15
  <section>
16
16
  <h1 className="text-2xl font-semibold tracking-tight">
17
17
  Something went wrong
@@ -6,6 +6,7 @@
6
6
  markup elsewhere. */
7
7
  @source "../app/**/*.{tsx,ts,jsx,js}";
8
8
  @source "../components/**/*.{tsx,ts,jsx,js}";
9
+ @source "../node_modules/@pylonsync/client/**/*.{tsx,ts,jsx,js}";
9
10
 
10
11
  @custom-variant dark (&:where(.dark, .dark *));
11
12
 
@@ -15,6 +16,9 @@
15
16
  on <html>. */
16
17
  :root {
17
18
  --radius: 0.625rem;
19
+ /* The app's own colors: links and the follow button, and a liked heart. */
20
+ --brand: #2563eb;
21
+ --like: #e11d48;
18
22
  --background: oklch(1 0 0);
19
23
  --foreground: oklch(0.141 0.005 285.823);
20
24
  --card: oklch(1 0 0);
@@ -83,6 +87,8 @@
83
87
  }
84
88
 
85
89
  @theme inline {
90
+ --color-brand: var(--brand);
91
+ --color-like: var(--like);
86
92
  --radius-sm: calc(var(--radius) - 4px);
87
93
  --radius-md: calc(var(--radius) - 2px);
88
94
  --radius-lg: var(--radius);
@@ -132,8 +138,27 @@
132
138
  body {
133
139
  background-color: var(--color-background);
134
140
  color: var(--color-foreground);
141
+ font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, -apple-system, sans-serif);
142
+ -webkit-font-smoothing: antialiased;
135
143
  }
136
144
  button {
137
145
  cursor: pointer;
138
146
  }
139
147
  }
148
+
149
+ /* The wordmark. Instrument Serif is declared in app.ts. */
150
+ .font-display {
151
+ font-family: var(--font-display, "Iowan Old Style", Georgia, serif);
152
+ }
153
+
154
+ /* The heart that pops over a photo on a double tap. */
155
+ @keyframes heart-pop {
156
+ 0% { transform: scale(0.2); opacity: 0; }
157
+ 15% { transform: scale(1.15); opacity: 1; }
158
+ 30% { transform: scale(0.95); }
159
+ 45%, 80% { transform: scale(1); opacity: 1; }
160
+ 100% { transform: scale(1); opacity: 0; }
161
+ }
162
+ .heart-pop {
163
+ animation: heart-pop 900ms ease-out forwards;
164
+ }
@@ -4,8 +4,8 @@ interface LayoutProps {
4
4
  children: React.ReactNode;
5
5
  }
6
6
 
7
- // The root layout wraps every page: a header and a centered column. The page
8
- // renders server-side first, then the feed hydrates into a live view.
7
+ // The document shell. The app chrome (sidebar and mobile bars) lives in
8
+ // `(app)/layout.tsx`, so a page outside that group renders bare.
9
9
  export default function RootLayout({ children }: LayoutProps) {
10
10
  return (
11
11
  <html lang="en">
@@ -14,14 +14,7 @@ export default function RootLayout({ children }: LayoutProps) {
14
14
  <meta name="viewport" content="width=device-width, initial-scale=1" />
15
15
  <title>__APP_NAME__</title>
16
16
  </head>
17
- <body className="min-h-screen bg-background text-foreground antialiased">
18
- <header className="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
19
- <div className="mx-auto max-w-lg px-4 py-3 text-sm font-semibold tracking-tight">
20
- __APP_NAME__
21
- </div>
22
- </header>
23
- <main className="mx-auto max-w-lg px-4 py-8">{children}</main>
24
- </body>
17
+ <body className="min-h-screen bg-background text-foreground antialiased">{children}</body>
25
18
  </html>
26
19
  );
27
20
  }
@@ -1,28 +1,28 @@
1
1
  import type { LlmsTxt } from "@pylonsync/react";
2
+ import { SITE } from "@/lib/site";
2
3
 
3
4
  // app/llms.ts → served at /llms.txt, in llmstxt.org format.
4
5
  //
5
6
  // This is the file an AI agent reads FIRST to decide what this site is and
6
- // whether it can help with the task in front of it. Keep the shape, rewrite the
7
- // copy: name the jobs this app is right for and the exact call to make. Generic
8
- // marketing copy does not read as guidance.
9
- //
10
- // The default export may be async, so it can enumerate pages from the database.
11
- const SITE = process.env.SITE_URL ?? "http://localhost:4321";
7
+ // whether it can help with the task in front of it. Keep the shape, rewrite
8
+ // the copy: name the jobs this app is right for and the exact call to make.
9
+ const BASE = process.env.SITE_URL ?? "http://localhost:4321";
12
10
 
13
11
  export default function llms(): LlmsTxt {
14
12
  return {
15
- title: "__APP_NAME__",
16
- summary: "A social feed with posts and likes.",
13
+ title: SITE.name,
14
+ summary: "A photo-sharing app: people post photos, follow each other, like, comment, and save posts.",
17
15
  details: [
18
- "Use this app to read the public feed, post, and like. Reading needs no account; posting needs a session.",
16
+ "Reading posts, profiles, and comments needs no account. Liking, commenting, following, and posting need a session; a visitor gets a guest session automatically.",
17
+ "Post pages are at /p/<id> and profiles at /u/<username>.",
19
18
  "Every page here is also readable as markdown: add `.md` to any path, or send `Accept: text/markdown`.",
20
19
  ],
21
20
  sections: [
22
21
  {
23
22
  title: "Pages",
24
23
  links: [
25
- { title: "Home", url: `${SITE}` },
24
+ { title: "Feed", url: `${BASE}/` },
25
+ { title: "Explore", url: `${BASE}/explore` },
26
26
  ],
27
27
  },
28
28
  ],
@@ -1,29 +1,16 @@
1
1
  import React from "react";
2
- import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
3
- import { Button } from "@/components/ui/button";
2
+ import { Link, type NotFoundProps } from "@pylonsync/react";
4
3
 
5
- // `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
6
- // a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
7
- // the buttons below use the client router. Not-found boundaries receive the
8
- // standard page props (and, matching Next, no `reset`).
4
+ // `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL, and when a
5
+ // page calls `response.notFound()`.
9
6
  export default function NotFound(_props: NotFoundProps) {
10
- const router = useRouter();
11
7
  return (
12
- <div className="space-y-6">
13
- <section>
14
- <h1 className="text-2xl font-semibold tracking-tight">404</h1>
15
- <p className="mt-2 text-muted-foreground">
16
- We couldn&apos;t find that page.
17
- </p>
18
- </section>
19
- <div className="flex items-center gap-3">
20
- <Button onClick={() => router.back()} variant="outline">
21
- ← Go back
22
- </Button>
23
- <Button asChild>
24
- <Link href="/">Home</Link>
25
- </Button>
26
- </div>
8
+ <div className="mx-auto max-w-md px-6 py-24 text-center">
9
+ <h1 className="text-xl font-semibold">This page is not available.</h1>
10
+ <p className="mt-2 text-sm text-zinc-500">The link may be broken, or the page may have been removed.</p>
11
+ <Link href="/" className="mt-6 inline-block text-sm font-semibold text-brand">
12
+ Back to the feed
13
+ </Link>
27
14
  </div>
28
15
  );
29
16
  }
@@ -1,12 +1,11 @@
1
1
  import type { Robots } from "@pylonsync/react";
2
2
 
3
3
  // app/robots.ts → served at /robots.txt. The default export may also be async.
4
- const SITE = process.env.SITE_URL ?? "http://localhost:4321";
4
+ const BASE = process.env.SITE_URL ?? "http://localhost:4321";
5
5
 
6
6
  export default function robots(): Robots {
7
7
  return {
8
- // Keep the authenticated app and the API out of the index.
9
- rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
10
- sitemap: `${SITE}/sitemap.xml`,
8
+ rules: { userAgent: "*", allow: "/", disallow: ["/api/"] },
9
+ sitemap: `${BASE}/sitemap.xml`,
11
10
  };
12
11
  }
@@ -1,27 +1,13 @@
1
1
  import type { Sitemap } from "@pylonsync/react";
2
2
 
3
- // app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
4
- // it can enumerate dynamic pages from your database. Point SITE_URL at your
5
- // domain in production.
6
- const SITE = process.env.SITE_URL ?? "http://localhost:4321";
3
+ // app/sitemap.ts → served at /sitemap.xml. The default export can be async,
4
+ // so it can list post and profile pages from the database once you have
5
+ // real ones. Point SITE_URL at your domain in production.
6
+ const BASE = process.env.SITE_URL ?? "http://localhost:4321";
7
7
 
8
8
  export default async function sitemap(): Promise<Sitemap> {
9
- // Only public pages belong here — /dashboard is private (and noindex), so
10
- // it's intentionally left out.
11
- const staticRoutes: Sitemap = [
12
- { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
13
- { url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
14
- { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
9
+ return [
10
+ { url: `${BASE}/`, changeFrequency: "hourly", priority: 1 },
11
+ { url: `${BASE}/explore`, changeFrequency: "hourly", priority: 0.8 },
15
12
  ];
16
-
17
- // The export is async, so you can enumerate dynamic pages from a DB read:
18
- //
19
- // const posts = await fetchPublishedPosts();
20
- // const postRoutes: Sitemap = posts.map((p) => ({
21
- // url: `${SITE}/blog/${p.slug}`,
22
- // lastModified: p.updatedAt,
23
- // }));
24
- // return [...staticRoutes, ...postRoutes];
25
-
26
- return staticRoutes;
27
13
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  entity,
3
3
  field,
4
+ font,
4
5
  policy,
5
6
  auth,
6
7
  buildManifest,
@@ -8,71 +9,167 @@ import {
8
9
  discoverFunctions,
9
10
  } from "@pylonsync/sdk";
10
11
 
11
- // A post in the public feed. `authorId: field.owner()` stamps the signed-in
12
- // (guest) user's id server-side, so an optimistic `db.insert("Post", { text })`
13
- // can't forge authorship. The feed itself is public-read (everyone sees every
14
- // post) — that's intentional for a social feed, NOT the insecure wide-open
15
- // default; writes are still owner-only.
12
+ // A photo-sharing app: profiles, photo posts, likes, comments, follows, and
13
+ // saves. Everything people post is public to read. Every write carries the
14
+ // writer's id, and the policies below (or the functions, for posts,
15
+ // comments, and profiles) check it against the session, so a client cannot
16
+ // post, like, or follow as someone else. Guest sessions
17
+ // (components/social/session.tsx) let a visitor post without a sign-up form.
18
+ //
19
+ // The ids are plain fields, not `field.owner()`, because the demo data in
20
+ // lib/seed.ts is written for twelve made-up people, and an owner field only
21
+ // accepts the caller's own id.
22
+ //
23
+ // • Profile — the public face of a user: username, name, bio, avatar.
24
+ // • Post — one photo and a caption.
25
+ // • Like, Save, Follow — join rows. A count is how many rows point at a thing.
26
+ // • Comment — text on a post.
27
+
28
+ const Profile = entity(
29
+ "Profile",
30
+ {
31
+ userId: field.string().unique(),
32
+ username: field.string().unique(),
33
+ displayName: field.string(),
34
+ bio: field.string().optional(),
35
+ avatarUrl: field.string().optional(),
36
+ createdAt: field.datetime().defaultNow(),
37
+ },
38
+ );
39
+
16
40
  const Post = entity(
17
41
  "Post",
18
42
  {
19
- authorId: field.string().owner(),
20
- text: field.string(),
43
+ authorId: field.string(),
44
+ imageUrl: field.string(),
45
+ caption: field.string().optional(),
46
+ // "square" (1:1) or "portrait" (4:5): how the feed crops the photo.
47
+ shape: field.string().default("square"),
21
48
  createdAt: field.datetime().defaultNow(),
22
49
  },
23
- { indexes: [{ name: "by_created", fields: ["createdAt"], unique: false }] },
50
+ {
51
+ indexes: [
52
+ { name: "by_created", fields: ["createdAt"], unique: false },
53
+ { name: "by_author", fields: ["authorId"], unique: false },
54
+ ],
55
+ },
24
56
  );
25
57
 
26
- // A like is a join row (one per user per post). To toggle a like the client
27
- // inserts or deletes this row; the like count for a post is just how many
28
- // Like rows point at it. `userId: field.owner()` keeps a like attributable to
29
- // exactly one user.
30
58
  const Like = entity(
31
59
  "Like",
32
60
  {
33
- userId: field.string().owner(),
61
+ userId: field.string(),
34
62
  postId: field.string(),
35
63
  createdAt: field.datetime().defaultNow(),
36
64
  },
37
65
  {
38
66
  indexes: [
39
67
  { name: "by_post", fields: ["postId"], unique: false },
40
- // One like per user per post — the unique index makes a double-like a
41
- // no-op at the storage layer.
68
+ // One like per person per post: a second tap is a no-op at storage.
42
69
  { name: "by_user_post", fields: ["userId", "postId"], unique: true },
43
70
  ],
44
71
  },
45
72
  );
46
73
 
47
- // An entity with no policy is denied to clients by default, so these
48
- // allow-lists are what make the feed work.
49
- // `allowInsert` is `auth.userId != null`, not `== data.authorId`: the owner
50
- // field is stamped by field.owner() *after* the policy check, so it's null at
51
- // insert-time. The stamp still guarantees the new row is owned by the caller,
52
- // and read/update/delete enforce ownership on the persisted row.
74
+ const Comment = entity(
75
+ "Comment",
76
+ {
77
+ postId: field.string(),
78
+ authorId: field.string(),
79
+ text: field.string(),
80
+ createdAt: field.datetime().defaultNow(),
81
+ },
82
+ { indexes: [{ name: "by_post", fields: ["postId"], unique: false }] },
83
+ );
84
+
85
+ const Follow = entity(
86
+ "Follow",
87
+ {
88
+ followerId: field.string(),
89
+ followingId: field.string(),
90
+ createdAt: field.datetime().defaultNow(),
91
+ },
92
+ {
93
+ indexes: [
94
+ { name: "by_following", fields: ["followingId"], unique: false },
95
+ { name: "by_pair", fields: ["followerId", "followingId"], unique: true },
96
+ ],
97
+ },
98
+ );
99
+
100
+ // Saves are private: only the person who saved a post can see that they did.
101
+ const Save = entity(
102
+ "Save",
103
+ {
104
+ userId: field.string(),
105
+ postId: field.string(),
106
+ createdAt: field.datetime().defaultNow(),
107
+ },
108
+ { indexes: [{ name: "by_user_post", fields: ["userId", "postId"], unique: true }] },
109
+ );
110
+
111
+ // An entity with no policy is denied to clients, so these allow-lists are
112
+ // what make the app work. An insert must carry the caller's own id; a delete
113
+ // is allowed only on the caller's own row.
114
+ //
115
+ // Profile, Post, and Comment writes go through functions (ensureProfile,
116
+ // updateProfile, createPost, addComment) so usernames, captions, and image
117
+ // URLs are checked on the server. Likes, follows, and saves are plain
118
+ // inserts and deletes from the client.
119
+ const profilePolicy = policy({
120
+ name: "profile_public",
121
+ entity: "Profile",
122
+ allowRead: "true",
123
+ allowInsert: "false",
124
+ allowUpdate: "false",
125
+ allowDelete: "false",
126
+ });
127
+
53
128
  const postPolicy = policy({
54
- name: "post_feed",
129
+ name: "post_public",
55
130
  entity: "Post",
56
131
  allowRead: "true",
57
- allowInsert: "auth.userId != null",
58
- allowUpdate: "auth.userId == data.authorId",
132
+ allowInsert: "false",
133
+ allowUpdate: "false",
59
134
  allowDelete: "auth.userId == data.authorId",
60
135
  });
61
136
 
62
137
  const likePolicy = policy({
63
- name: "like_access",
138
+ name: "like_public",
64
139
  entity: "Like",
65
140
  allowRead: "true",
66
- allowInsert: "auth.userId != null",
141
+ allowInsert: "auth.userId != null && data.userId == auth.userId",
142
+ allowUpdate: "false",
143
+ allowDelete: "auth.userId == data.userId",
144
+ });
145
+
146
+ const commentPolicy = policy({
147
+ name: "comment_public",
148
+ entity: "Comment",
149
+ allowRead: "true",
150
+ allowInsert: "false",
151
+ allowUpdate: "false",
152
+ allowDelete: "auth.userId == data.authorId",
153
+ });
154
+
155
+ const followPolicy = policy({
156
+ name: "follow_public",
157
+ entity: "Follow",
158
+ allowRead: "true",
159
+ allowInsert: "auth.userId != null && data.followerId == auth.userId && data.followingId != auth.userId",
160
+ allowUpdate: "false",
161
+ allowDelete: "auth.userId == data.followerId",
162
+ });
163
+
164
+ const savePolicy = policy({
165
+ name: "save_private",
166
+ entity: "Save",
167
+ allowRead: "auth.userId == data.userId",
168
+ allowInsert: "auth.userId != null && data.userId == auth.userId",
67
169
  allowUpdate: "false",
68
170
  allowDelete: "auth.userId == data.userId",
69
171
  });
70
172
 
71
- // `pylon dev` serves the SSR feed and the API from one port. Guest sessions
72
- // (via `<EnsureGuest>` on the page) let every visitor post + like with no
73
- // login. Natural next steps: a `Profile` entity (displayName/avatar keyed by
74
- // userId) to show names instead of ids, and a `Follow` join entity
75
- // (followerId/followedId) to scope the feed to people you follow.
76
173
  // Every non-internal function in functions/ becomes a manifest entry —
77
174
  // this is what makes them show up in /api/manifest, the OpenAPI spec,
78
175
  // and `pylon codegen`.
@@ -81,11 +178,33 @@ const fns = await discoverFunctions();
81
178
  const manifest = buildManifest({
82
179
  name: "__APP_NAME__",
83
180
  version: "0.1.0",
84
- entities: [Post, Like],
181
+ entities: [Profile, Post, Like, Comment, Follow, Save],
85
182
  queries: fns.queries,
86
183
  actions: fns.actions,
87
- policies: [postPolicy, likePolicy],
184
+ policies: [profilePolicy, postPolicy, likePolicy, commentPolicy, followPolicy, savePolicy],
88
185
  auth: auth(),
186
+ // Self-hosted: the build fetches the woff2 files and serves them from this
187
+ // app, so there is no third-party font request.
188
+ fonts: [
189
+ font({
190
+ family: "Inter",
191
+ variable: "--font-sans",
192
+ weights: ["400", "500", "600", "700"],
193
+ subsets: ["latin"],
194
+ display: "swap",
195
+ preload: true,
196
+ }),
197
+ // The wordmark.
198
+ font({
199
+ family: "Instrument Serif",
200
+ variable: "--font-display",
201
+ weights: ["400"],
202
+ styles: ["normal", "italic"],
203
+ subsets: ["latin"],
204
+ display: "swap",
205
+ preload: true,
206
+ }),
207
+ ],
89
208
  routes: await discoverAppRoutes(),
90
209
  });
91
210