@pylonsync/create-pylon 0.3.273 → 0.3.275

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 (323) hide show
  1. package/bin/create-pylon.js +80 -0
  2. package/package.json +1 -1
  3. package/templates/ARCHETYPES.md +339 -0
  4. package/templates/agency/.env.example +12 -0
  5. package/templates/agency/AGENTS.md +61 -0
  6. package/templates/agency/README.md +90 -0
  7. package/templates/agency/app/auth-form.tsx +129 -0
  8. package/templates/agency/app/contact-form.tsx +258 -0
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +286 -0
  10. package/templates/agency/app/dashboard/page.tsx +70 -0
  11. package/templates/agency/app/error.tsx +26 -0
  12. package/templates/agency/app/globals.css +148 -0
  13. package/templates/agency/app/layout.tsx +174 -0
  14. package/templates/agency/app/login/page.tsx +39 -0
  15. package/templates/agency/app/not-found.tsx +19 -0
  16. package/templates/agency/app/page.tsx +207 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/sitemap.ts +9 -0
  19. package/templates/agency/app.ts +135 -0
  20. package/templates/agency/components/marketing.tsx +148 -0
  21. package/templates/agency/components/section-scroller.tsx +35 -0
  22. package/templates/agency/components/ui/button.tsx +56 -0
  23. package/templates/agency/components/ui/card.tsx +90 -0
  24. package/templates/agency/components.json +20 -0
  25. package/templates/agency/functions/bookInquiry.ts +42 -0
  26. package/templates/agency/functions/declineInquiry.ts +41 -0
  27. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  28. package/templates/agency/functions/seedCapacity.ts +26 -0
  29. package/templates/agency/functions/setCapacity.ts +32 -0
  30. package/templates/agency/functions/submitInquiry.ts +55 -0
  31. package/templates/agency/gitignore +10 -0
  32. package/templates/agency/lib/agency.ts +27 -0
  33. package/templates/agency/lib/owner.ts +26 -0
  34. package/templates/agency/lib/site.config.ts +239 -0
  35. package/templates/agency/lib/utils.ts +10 -0
  36. package/templates/agency/package.json +34 -0
  37. package/templates/agency/tsconfig.json +18 -0
  38. package/templates/ai-chat/.env.example +33 -0
  39. package/templates/ai-chat/AGENTS.md +61 -0
  40. package/templates/ai-chat/README.md +99 -0
  41. package/templates/ai-chat/app/auth-form.tsx +124 -0
  42. package/templates/ai-chat/app/chat-client.tsx +414 -0
  43. package/templates/ai-chat/app/error.tsx +26 -0
  44. package/templates/ai-chat/app/globals.css +148 -0
  45. package/templates/ai-chat/app/layout.tsx +75 -0
  46. package/templates/ai-chat/app/login/page.tsx +39 -0
  47. package/templates/ai-chat/app/not-found.tsx +19 -0
  48. package/templates/ai-chat/app/page.tsx +23 -0
  49. package/templates/ai-chat/app.ts +121 -0
  50. package/templates/ai-chat/components.json +20 -0
  51. package/templates/ai-chat/gitignore +10 -0
  52. package/templates/ai-chat/lib/site.config.ts +103 -0
  53. package/templates/ai-chat/lib/utils.ts +10 -0
  54. package/templates/ai-chat/package.json +34 -0
  55. package/templates/ai-chat/tsconfig.json +18 -0
  56. package/templates/ai-studio/.env.example +19 -0
  57. package/templates/ai-studio/AGENTS.md +61 -0
  58. package/templates/ai-studio/README.md +83 -0
  59. package/templates/ai-studio/app/auth-form.tsx +124 -0
  60. package/templates/ai-studio/app/error.tsx +26 -0
  61. package/templates/ai-studio/app/globals.css +148 -0
  62. package/templates/ai-studio/app/layout.tsx +75 -0
  63. package/templates/ai-studio/app/login/page.tsx +39 -0
  64. package/templates/ai-studio/app/not-found.tsx +19 -0
  65. package/templates/ai-studio/app/page.tsx +34 -0
  66. package/templates/ai-studio/app/studio-client.tsx +214 -0
  67. package/templates/ai-studio/app.ts +108 -0
  68. package/templates/ai-studio/components.json +20 -0
  69. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  70. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  71. package/templates/ai-studio/functions/generate.ts +42 -0
  72. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  73. package/templates/ai-studio/gitignore +10 -0
  74. package/templates/ai-studio/lib/site.config.ts +80 -0
  75. package/templates/ai-studio/lib/studio.ts +52 -0
  76. package/templates/ai-studio/lib/utils.ts +10 -0
  77. package/templates/ai-studio/package.json +34 -0
  78. package/templates/ai-studio/tsconfig.json +18 -0
  79. package/templates/creator/.env.example +12 -0
  80. package/templates/creator/AGENTS.md +61 -0
  81. package/templates/creator/README.md +67 -0
  82. package/templates/creator/app/auth-form.tsx +129 -0
  83. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  84. package/templates/creator/app/dashboard/page.tsx +70 -0
  85. package/templates/creator/app/error.tsx +26 -0
  86. package/templates/creator/app/globals.css +148 -0
  87. package/templates/creator/app/layout.tsx +160 -0
  88. package/templates/creator/app/login/page.tsx +39 -0
  89. package/templates/creator/app/newsletter-signup.tsx +162 -0
  90. package/templates/creator/app/not-found.tsx +19 -0
  91. package/templates/creator/app/page.tsx +160 -0
  92. package/templates/creator/app/robots.ts +12 -0
  93. package/templates/creator/app/sitemap.ts +9 -0
  94. package/templates/creator/app.ts +134 -0
  95. package/templates/creator/components/marketing.tsx +148 -0
  96. package/templates/creator/components/section-scroller.tsx +35 -0
  97. package/templates/creator/components/ui/button.tsx +56 -0
  98. package/templates/creator/components/ui/card.tsx +90 -0
  99. package/templates/creator/components.json +20 -0
  100. package/templates/creator/functions/subscribe.ts +82 -0
  101. package/templates/creator/functions/subscriberStats.ts +75 -0
  102. package/templates/creator/gitignore +10 -0
  103. package/templates/creator/lib/owner.ts +26 -0
  104. package/templates/creator/lib/site.config.ts +173 -0
  105. package/templates/creator/lib/stats.ts +30 -0
  106. package/templates/creator/lib/utils.ts +10 -0
  107. package/templates/creator/package.json +34 -0
  108. package/templates/creator/tsconfig.json +18 -0
  109. package/templates/default/app/layout.tsx +26 -27
  110. package/templates/default/app/page.tsx +90 -274
  111. package/templates/default/lib/products.ts +9 -122
  112. package/templates/default/lib/site.config.ts +739 -0
  113. package/templates/default/lib/site.ts +14 -261
  114. package/templates/directory/.env.example +12 -0
  115. package/templates/directory/AGENTS.md +61 -0
  116. package/templates/directory/README.md +80 -0
  117. package/templates/directory/app/auth-form.tsx +129 -0
  118. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  119. package/templates/directory/app/dashboard/page.tsx +70 -0
  120. package/templates/directory/app/directory-browse.tsx +328 -0
  121. package/templates/directory/app/error.tsx +26 -0
  122. package/templates/directory/app/globals.css +148 -0
  123. package/templates/directory/app/layout.tsx +171 -0
  124. package/templates/directory/app/login/page.tsx +39 -0
  125. package/templates/directory/app/not-found.tsx +19 -0
  126. package/templates/directory/app/page.tsx +50 -0
  127. package/templates/directory/app/robots.ts +12 -0
  128. package/templates/directory/app/sitemap.ts +9 -0
  129. package/templates/directory/app/submit/page.tsx +30 -0
  130. package/templates/directory/app/submit-form.tsx +151 -0
  131. package/templates/directory/app.ts +146 -0
  132. package/templates/directory/components/marketing.tsx +148 -0
  133. package/templates/directory/components/section-scroller.tsx +35 -0
  134. package/templates/directory/components/ui/button.tsx +56 -0
  135. package/templates/directory/components/ui/card.tsx +90 -0
  136. package/templates/directory/components.json +20 -0
  137. package/templates/directory/functions/approveSubmission.ts +45 -0
  138. package/templates/directory/functions/rejectSubmission.ts +20 -0
  139. package/templates/directory/functions/seedListings.ts +33 -0
  140. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  141. package/templates/directory/functions/submitListing.ts +63 -0
  142. package/templates/directory/functions/upvote.ts +24 -0
  143. package/templates/directory/gitignore +10 -0
  144. package/templates/directory/lib/directory.ts +45 -0
  145. package/templates/directory/lib/owner.ts +26 -0
  146. package/templates/directory/lib/site.config.ts +130 -0
  147. package/templates/directory/lib/utils.ts +10 -0
  148. package/templates/directory/package.json +34 -0
  149. package/templates/directory/tsconfig.json +18 -0
  150. package/templates/local-service/.env.example +12 -0
  151. package/templates/local-service/AGENTS.md +61 -0
  152. package/templates/local-service/README.md +82 -0
  153. package/templates/local-service/app/auth-form.tsx +129 -0
  154. package/templates/local-service/app/booking-widget.tsx +399 -0
  155. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  156. package/templates/local-service/app/dashboard/page.tsx +63 -0
  157. package/templates/local-service/app/error.tsx +26 -0
  158. package/templates/local-service/app/globals.css +148 -0
  159. package/templates/local-service/app/layout.tsx +151 -0
  160. package/templates/local-service/app/login/page.tsx +39 -0
  161. package/templates/local-service/app/not-found.tsx +19 -0
  162. package/templates/local-service/app/page.tsx +233 -0
  163. package/templates/local-service/app/robots.ts +12 -0
  164. package/templates/local-service/app/sitemap.ts +9 -0
  165. package/templates/local-service/app.ts +131 -0
  166. package/templates/local-service/components/marketing.tsx +162 -0
  167. package/templates/local-service/components/section-scroller.tsx +35 -0
  168. package/templates/local-service/components/ui/button.tsx +56 -0
  169. package/templates/local-service/components/ui/card.tsx +90 -0
  170. package/templates/local-service/components.json +20 -0
  171. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  172. package/templates/local-service/functions/cancelBooking.ts +27 -0
  173. package/templates/local-service/functions/confirmBooking.ts +18 -0
  174. package/templates/local-service/functions/createBooking.ts +98 -0
  175. package/templates/local-service/gitignore +10 -0
  176. package/templates/local-service/lib/booking.ts +24 -0
  177. package/templates/local-service/lib/owner.ts +26 -0
  178. package/templates/local-service/lib/site.config.ts +232 -0
  179. package/templates/local-service/lib/slots.ts +97 -0
  180. package/templates/local-service/lib/utils.ts +10 -0
  181. package/templates/local-service/package.json +34 -0
  182. package/templates/local-service/tsconfig.json +18 -0
  183. package/templates/marketplace/.env.example +9 -0
  184. package/templates/marketplace/AGENTS.md +61 -0
  185. package/templates/marketplace/README.md +78 -0
  186. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  187. package/templates/marketplace/app/error.tsx +26 -0
  188. package/templates/marketplace/app/globals.css +64 -0
  189. package/templates/marketplace/app/layout.tsx +60 -0
  190. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  191. package/templates/marketplace/app/me/page.tsx +15 -0
  192. package/templates/marketplace/app/not-found.tsx +20 -0
  193. package/templates/marketplace/app/page.tsx +159 -0
  194. package/templates/marketplace/app/robots.ts +12 -0
  195. package/templates/marketplace/app/sell/page.tsx +26 -0
  196. package/templates/marketplace/app/sitemap.ts +14 -0
  197. package/templates/marketplace/app.ts +190 -0
  198. package/templates/marketplace/client/AuthNav.tsx +46 -0
  199. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  200. package/templates/marketplace/client/LoginCard.tsx +130 -0
  201. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  202. package/templates/marketplace/client/MyMarket.tsx +180 -0
  203. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  204. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  205. package/templates/marketplace/client/SellForm.tsx +160 -0
  206. package/templates/marketplace/client/WatchButton.tsx +88 -0
  207. package/templates/marketplace/client/market.ts +341 -0
  208. package/templates/marketplace/functions/buyNow.ts +78 -0
  209. package/templates/marketplace/functions/makeOffer.ts +65 -0
  210. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  211. package/templates/marketplace/functions/seedMarket.ts +90 -0
  212. package/templates/marketplace/gitignore +10 -0
  213. package/templates/marketplace/package.json +35 -0
  214. package/templates/marketplace/tsconfig.json +14 -0
  215. package/templates/marketplace/ui/badge.tsx +30 -0
  216. package/templates/marketplace/ui/button.tsx +49 -0
  217. package/templates/marketplace/ui/card.tsx +48 -0
  218. package/templates/marketplace/ui/input.tsx +17 -0
  219. package/templates/marketplace/ui/label.tsx +18 -0
  220. package/templates/marketplace/ui/textarea.tsx +17 -0
  221. package/templates/marketplace/ui/tokens.css +32 -0
  222. package/templates/marketplace/ui/utils.ts +6 -0
  223. package/templates/restaurant/.env.example +12 -0
  224. package/templates/restaurant/AGENTS.md +61 -0
  225. package/templates/restaurant/README.md +77 -0
  226. package/templates/restaurant/app/auth-form.tsx +129 -0
  227. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  228. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  229. package/templates/restaurant/app/error.tsx +26 -0
  230. package/templates/restaurant/app/globals.css +148 -0
  231. package/templates/restaurant/app/layout.tsx +151 -0
  232. package/templates/restaurant/app/login/page.tsx +39 -0
  233. package/templates/restaurant/app/not-found.tsx +19 -0
  234. package/templates/restaurant/app/page.tsx +194 -0
  235. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  236. package/templates/restaurant/app/robots.ts +12 -0
  237. package/templates/restaurant/app/sitemap.ts +9 -0
  238. package/templates/restaurant/app.ts +115 -0
  239. package/templates/restaurant/components/marketing.tsx +162 -0
  240. package/templates/restaurant/components/section-scroller.tsx +35 -0
  241. package/templates/restaurant/components/ui/button.tsx +56 -0
  242. package/templates/restaurant/components/ui/card.tsx +90 -0
  243. package/templates/restaurant/components.json +20 -0
  244. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  245. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  246. package/templates/restaurant/functions/createReservation.ts +92 -0
  247. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  248. package/templates/restaurant/gitignore +10 -0
  249. package/templates/restaurant/lib/owner.ts +26 -0
  250. package/templates/restaurant/lib/reservation.ts +22 -0
  251. package/templates/restaurant/lib/site.config.ts +218 -0
  252. package/templates/restaurant/lib/slots.ts +55 -0
  253. package/templates/restaurant/lib/utils.ts +10 -0
  254. package/templates/restaurant/package.json +34 -0
  255. package/templates/restaurant/tsconfig.json +18 -0
  256. package/templates/shop/.env.example +32 -0
  257. package/templates/shop/AGENTS.md +61 -0
  258. package/templates/shop/README.md +102 -0
  259. package/templates/shop/app/auth-form.tsx +129 -0
  260. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  261. package/templates/shop/app/dashboard/page.tsx +59 -0
  262. package/templates/shop/app/error.tsx +26 -0
  263. package/templates/shop/app/globals.css +148 -0
  264. package/templates/shop/app/layout.tsx +160 -0
  265. package/templates/shop/app/login/page.tsx +39 -0
  266. package/templates/shop/app/not-found.tsx +19 -0
  267. package/templates/shop/app/page.tsx +95 -0
  268. package/templates/shop/app/robots.ts +12 -0
  269. package/templates/shop/app/shop-client.tsx +436 -0
  270. package/templates/shop/app/sitemap.ts +9 -0
  271. package/templates/shop/app/success/page.tsx +33 -0
  272. package/templates/shop/app.ts +134 -0
  273. package/templates/shop/components/marketing.tsx +96 -0
  274. package/templates/shop/components/section-scroller.tsx +35 -0
  275. package/templates/shop/components/ui/button.tsx +56 -0
  276. package/templates/shop/components/ui/card.tsx +90 -0
  277. package/templates/shop/components.json +20 -0
  278. package/templates/shop/functions/cancelOrder.ts +33 -0
  279. package/templates/shop/functions/checkout.ts +130 -0
  280. package/templates/shop/functions/fulfillOrder.ts +17 -0
  281. package/templates/shop/functions/markGroupPaid.ts +26 -0
  282. package/templates/shop/functions/ordersForOwner.ts +28 -0
  283. package/templates/shop/functions/releaseGroup.ts +36 -0
  284. package/templates/shop/functions/reserveCart.ts +87 -0
  285. package/templates/shop/functions/restockProduct.ts +23 -0
  286. package/templates/shop/functions/seedProducts.ts +30 -0
  287. package/templates/shop/functions/stripeWebhook.ts +72 -0
  288. package/templates/shop/gitignore +10 -0
  289. package/templates/shop/lib/owner.ts +26 -0
  290. package/templates/shop/lib/shop.ts +45 -0
  291. package/templates/shop/lib/site.config.ts +198 -0
  292. package/templates/shop/lib/utils.ts +10 -0
  293. package/templates/shop/package.json +35 -0
  294. package/templates/shop/tsconfig.json +18 -0
  295. package/templates/waitlist/.env.example +12 -0
  296. package/templates/waitlist/AGENTS.md +61 -0
  297. package/templates/waitlist/README.md +81 -0
  298. package/templates/waitlist/app/auth-form.tsx +129 -0
  299. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  300. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  301. package/templates/waitlist/app/error.tsx +26 -0
  302. package/templates/waitlist/app/globals.css +148 -0
  303. package/templates/waitlist/app/layout.tsx +158 -0
  304. package/templates/waitlist/app/login/page.tsx +39 -0
  305. package/templates/waitlist/app/not-found.tsx +19 -0
  306. package/templates/waitlist/app/page.tsx +119 -0
  307. package/templates/waitlist/app/robots.ts +12 -0
  308. package/templates/waitlist/app/sitemap.ts +9 -0
  309. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  310. package/templates/waitlist/app.ts +134 -0
  311. package/templates/waitlist/components/marketing.tsx +96 -0
  312. package/templates/waitlist/components/ui/button.tsx +56 -0
  313. package/templates/waitlist/components/ui/card.tsx +90 -0
  314. package/templates/waitlist/components.json +20 -0
  315. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  316. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  317. package/templates/waitlist/gitignore +10 -0
  318. package/templates/waitlist/lib/owner.ts +26 -0
  319. package/templates/waitlist/lib/site.config.ts +178 -0
  320. package/templates/waitlist/lib/stats.ts +30 -0
  321. package/templates/waitlist/lib/utils.ts +10 -0
  322. package/templates/waitlist/package.json +34 -0
  323. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,146 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ } from "@pylonsync/sdk";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // directory — a curated, searchable listing site (a "best X" / tools / local
12
+ // directory). It's the template that shows off Pylon's FULL-TEXT SEARCH +
13
+ // FACETS: the browse page is a live `db.useSearch` over the Listing table —
14
+ // type in the box and results + facet counts update instantly, all server-
15
+ // backed, no search service. The realtime hook is live upvotes: tap the arrow
16
+ // and the count ticks up for EVERYONE with the page open (no refresh).
17
+ //
18
+ // Three entities:
19
+ // • Listing — an approved, public entry: name, what it is, the link, a
20
+ // category + tags, and a live vote count. PII-free, so it's
21
+ // PUBLIC-READ and indexed for search. Only functions write it.
22
+ // • Submission — a "submit your listing" lead. Holds the submitter's name +
23
+ // email (PII) alongside the proposed entry, so it denies ALL
24
+ // client reads/writes. The owner reviews submissions and
25
+ // approves them into public Listings; the email never leaks.
26
+ // • User — the curator/owner's account for the moderation dashboard.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ // A public directory entry. No PII — everything here is meant to be browsed.
30
+ // The `search:` block tells Pylon to build FTS5 + facet shadow tables: `text`
31
+ // fields are full-text indexed, `facets` get live count sidebars, `sortable`
32
+ // fields can order results. `db.useSearch("Listing", …)` drives the browse UI.
33
+ const Listing = entity(
34
+ "Listing",
35
+ {
36
+ name: field.string(),
37
+ tagline: field.string(),
38
+ url: field.string(), // the link this entry points to
39
+ category: field.string(),
40
+ tags: field.string().optional(), // comma-separated, shown as chips
41
+ description: field.string().optional(),
42
+ votes: field.int().default(0),
43
+ featured: field.boolean().default(false),
44
+ createdAt: field.datetime().defaultNow(),
45
+ },
46
+ {
47
+ indexes: [
48
+ { name: "by_category", fields: ["category"], unique: false },
49
+ { name: "by_votes", fields: ["votes"], unique: false },
50
+ { name: "by_created", fields: ["createdAt"], unique: false },
51
+ ],
52
+ search: {
53
+ text: ["name", "tagline", "description"],
54
+ facets: ["category"],
55
+ sortable: ["votes", "createdAt"],
56
+ },
57
+ },
58
+ );
59
+
60
+ // A pending submission. PII (submitter email) + un-vetted content, so it's
61
+ // deny-all; submitListing writes it, the owner reads it via submissionsForOwner.
62
+ // `status` tracks moderation: "new" → "approved" (becomes a Listing) | "rejected".
63
+ const Submission = entity(
64
+ "Submission",
65
+ {
66
+ submitterName: field.string(),
67
+ submitterEmail: field.string(),
68
+ name: field.string(),
69
+ tagline: field.string(),
70
+ url: field.string(),
71
+ category: field.string(),
72
+ tags: field.string().optional(),
73
+ description: field.string().optional(),
74
+ status: field.string().default("new"), // "new" | "approved" | "rejected"
75
+ createdAt: field.datetime().defaultNow(),
76
+ },
77
+ { indexes: [{ name: "by_created", fields: ["createdAt"], unique: false }] },
78
+ );
79
+
80
+ // The curator's account. Email/password auth is built in against "User"
81
+ // (passwordHash is server-only). The dashboard is gated to PYLON_OWNER_EMAIL.
82
+ const User = entity(
83
+ "User",
84
+ {
85
+ email: field.string(),
86
+ displayName: field.string().optional(),
87
+ passwordHash: field.string().serverOnly().optional(),
88
+ avatarColor: field.string().optional(),
89
+ emailVerified: field.datetime().optional(),
90
+ createdAt: field.datetime().defaultNow(),
91
+ },
92
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
93
+ );
94
+
95
+ // Listings are PUBLIC-READ — the whole point is a browsable, searchable
96
+ // directory. Clients can't WRITE them; only seedListings / approveSubmission
97
+ // create them and the upvote mutation bumps the vote count, all server-side.
98
+ const listingPolicy = policy({
99
+ name: "listing_public_read",
100
+ entity: "Listing",
101
+ allowRead: "true",
102
+ allowInsert: "false",
103
+ allowUpdate: "false",
104
+ allowDelete: "false",
105
+ });
106
+
107
+ // PRIVACY — Submission holds the submitter's name + email, so it denies EVERY
108
+ // client read and write. No `db.useQuery("Submission")` can pull a row; writes
109
+ // happen only inside submitListing / approveSubmission / rejectSubmission; reads
110
+ // only inside the owner-gated submissionsForOwner. The directory must never leak
111
+ // who submitted what — this guarantees it.
112
+ const submissionPolicy = policy({
113
+ name: "submission_private",
114
+ entity: "Submission",
115
+ allowRead: "false",
116
+ allowInsert: "false",
117
+ allowUpdate: "false",
118
+ allowDelete: "false",
119
+ });
120
+
121
+ const userPolicy = policy({
122
+ name: "user_self",
123
+ entity: "User",
124
+ allowRead: "auth.userId == data.id",
125
+ allowInsert: "false",
126
+ allowUpdate: "false",
127
+ allowDelete: "false",
128
+ });
129
+
130
+ const manifest = buildManifest({
131
+ name: "__APP_NAME__",
132
+ version: "0.1.0",
133
+ entities: [Listing, Submission, User],
134
+ // seedListings / submitListing / upvote (public) + submissionsForOwner /
135
+ // approveSubmission / rejectSubmission (owner-gated) live in functions/ and
136
+ // are discovered automatically.
137
+ queries: [],
138
+ actions: [],
139
+ policies: [listingPolicy, submissionPolicy, userPolicy],
140
+ auth: auth(),
141
+ routes: await discoverAppRoutes(),
142
+ });
143
+
144
+ console.log(JSON.stringify(manifest, null, 2));
145
+
146
+ export default manifest;
@@ -0,0 +1,148 @@
1
+ import React from "react";
2
+
3
+ // Reusable presentational pieces for the landing page. All server-rendered —
4
+ // no client JS. Restyle here and the whole page follows. The brand accent
5
+ // (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
6
+ // app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
7
+
8
+ // Shared container: a contained, centered column.
9
+ export const WRAP = "mx-auto w-full max-w-5xl px-6";
10
+
11
+ export function Eyebrow({ children }: { children: React.ReactNode }) {
12
+ return (
13
+ <p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
14
+ {children}
15
+ </p>
16
+ );
17
+ }
18
+
19
+ // "New / Coming soon"-style pill for the hero.
20
+ export function Badge({ children }: { children: React.ReactNode }) {
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>
58
+ );
59
+ }
60
+
61
+ // A grid of value props — icon + title + body.
62
+ export function FeatureGrid({
63
+ items,
64
+ }: {
65
+ items: { title: string; body: string; icon?: string }[];
66
+ }) {
67
+ return (
68
+ <div className="grid gap-6 sm:grid-cols-3">
69
+ {items.map((f) => (
70
+ <div key={f.title}>
71
+ {f.icon ? (
72
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
73
+ {f.icon}
74
+ </span>
75
+ ) : null}
76
+ <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
77
+ {f.title}
78
+ </h3>
79
+ <p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
80
+ {f.body}
81
+ </p>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
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
+ }
97
+
98
+ // A deliberately-obvious image placeholder. Real sites drop a photo here; this
99
+ // makes the spot unmistakable — dashed border, a photo glyph, and a one-line
100
+ // "swap this" instruction telling you exactly what to replace and where. Looks
101
+ // tidy enough to demo, but no one will mistake it for a finished design.
102
+ //
103
+ // shape — "landscape" | "portrait" | "square" | "circle"
104
+ // title — what photo belongs here ("Your headshot")
105
+ // hint — how to replace it ("Replace in app/page.tsx")
106
+ export function ImagePlaceholder({
107
+ shape = "landscape",
108
+ title,
109
+ hint,
110
+ className = "",
111
+ }: {
112
+ shape?: "landscape" | "portrait" | "square" | "circle";
113
+ title: string;
114
+ hint?: string;
115
+ className?: string;
116
+ }) {
117
+ const aspect =
118
+ shape === "portrait"
119
+ ? "aspect-[4/5]"
120
+ : shape === "square" || shape === "circle"
121
+ ? "aspect-square"
122
+ : "aspect-[4/3]";
123
+ const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
124
+ return (
125
+ <div
126
+ className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
127
+ >
128
+ <div className="px-4 text-center">
129
+ <svg
130
+ className="mx-auto size-7 text-zinc-300"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="1.5"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ aria-hidden
138
+ >
139
+ <rect x="3" y="3" width="18" height="18" rx="2" />
140
+ <circle cx="9" cy="9" r="1.6" />
141
+ <path d="m21 15-4.5-4.5L7 20" />
142
+ </svg>
143
+ <p className="mt-2 text-[12.5px] font-medium text-zinc-500">{title}</p>
144
+ {hint ? <p className="mt-1 text-[11px] leading-snug text-zinc-400">{hint}</p> : null}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ // Makes in-page section links work. A hydrated Pylon page updates the URL for a
6
+ // plain `<a href="#section">` click but doesn't perform the browser's native
7
+ // fragment scroll, so the page jumps nowhere. This installs ONE delegated click
8
+ // handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
9
+ //
10
+ // Render it once (in the root layout). Renders nothing. Real route links should
11
+ // still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
12
+ export function SectionScroller() {
13
+ useEffect(() => {
14
+ function onClick(e: MouseEvent) {
15
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
16
+ return;
17
+ }
18
+ const target = e.target as Element | null;
19
+ const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
20
+ if (!link) return;
21
+ const href = link.getAttribute("href") || "";
22
+ const id = href.slice(href.indexOf("#") + 1);
23
+ if (!id) return;
24
+ const el = document.getElementById(id);
25
+ if (!el) return; // target not on this page — leave it to the browser
26
+ e.preventDefault();
27
+ el.scrollIntoView({ block: "start" });
28
+ history.replaceState(null, "", "#" + id);
29
+ }
30
+ document.addEventListener("click", onClick);
31
+ return () => document.removeEventListener("click", onClick);
32
+ }, []);
33
+
34
+ return null;
35
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-10 rounded-md px-8",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ },
34
+ );
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean;
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button";
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ );
52
+ },
53
+ );
54
+ Button.displayName = "Button";
55
+
56
+ export { Button, buttonVariants };
@@ -0,0 +1,90 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "rounded-xl border bg-card text-card-foreground shadow-sm",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({
19
+ className,
20
+ ...props
21
+ }: React.HTMLAttributes<HTMLDivElement>) {
22
+ return (
23
+ <div
24
+ data-slot="card-header"
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({
32
+ className,
33
+ ...props
34
+ }: React.HTMLAttributes<HTMLDivElement>) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function CardDescription({
45
+ className,
46
+ ...props
47
+ }: React.HTMLAttributes<HTMLDivElement>) {
48
+ return (
49
+ <div
50
+ data-slot="card-description"
51
+ className={cn("text-sm text-muted-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function CardContent({
58
+ className,
59
+ ...props
60
+ }: React.HTMLAttributes<HTMLDivElement>) {
61
+ return (
62
+ <div
63
+ data-slot="card-content"
64
+ className={cn("p-6 pt-0", className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ function CardFooter({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) {
74
+ return (
75
+ <div
76
+ data-slot="card-footer"
77
+ className={cn("flex items-center p-6 pt-0", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export {
84
+ Card,
85
+ CardHeader,
86
+ CardFooter,
87
+ CardTitle,
88
+ CardDescription,
89
+ CardContent,
90
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true
11
+ },
12
+ "aliases": {
13
+ "components": "@/components",
14
+ "utils": "@/lib/utils",
15
+ "ui": "@/components/ui",
16
+ "lib": "@/lib",
17
+ "hooks": "@/hooks"
18
+ },
19
+ "iconLibrary": "lucide"
20
+ }
@@ -0,0 +1,45 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // approveSubmission — owner-only. Turns a pending Submission into a PUBLIC
5
+ // Listing (copying only the PII-free fields — the submitter's name/email stay
6
+ // behind in the deny-all Submission table) and marks the submission approved.
7
+ // The new Listing syncs straight to every open browse grid.
8
+ export default mutation<{ submissionId: string }, { ok: boolean }>({
9
+ auth: "user",
10
+ args: { submissionId: v.id("Submission") },
11
+ async handler(ctx, args) {
12
+ const me = await ctx.db.get("User", ctx.auth.userId);
13
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
14
+ throw ctx.error("POLICY_DENIED", "Only the owner can moderate submissions.");
15
+ }
16
+ const sub = (await ctx.db.get("Submission", args.submissionId)) as
17
+ | {
18
+ name: string;
19
+ tagline: string;
20
+ url: string;
21
+ category: string;
22
+ tags?: string | null;
23
+ description?: string | null;
24
+ status: string;
25
+ }
26
+ | null;
27
+ if (!sub) throw ctx.error("NOT_FOUND", "Submission not found.");
28
+ if (sub.status === "approved") return { ok: true }; // idempotent
29
+
30
+ // Copy ONLY the public fields — never the submitter's PII.
31
+ await ctx.db.unsafe.insert("Listing", {
32
+ name: sub.name,
33
+ tagline: sub.tagline,
34
+ url: sub.url,
35
+ category: sub.category,
36
+ tags: sub.tags ?? null,
37
+ description: sub.description ?? null,
38
+ votes: 0,
39
+ featured: false,
40
+ createdAt: new Date().toISOString(),
41
+ });
42
+ await ctx.db.unsafe.update("Submission", args.submissionId, { status: "approved" });
43
+ return { ok: true };
44
+ },
45
+ });
@@ -0,0 +1,20 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // rejectSubmission — owner-only. Marks a submission rejected (it never becomes
5
+ // a public Listing). Kept (not deleted) so the owner has a record; a real app
6
+ // might purge rejected rows on a schedule.
7
+ export default mutation<{ submissionId: string }, { ok: boolean }>({
8
+ auth: "user",
9
+ args: { submissionId: v.id("Submission") },
10
+ async handler(ctx, args) {
11
+ const me = await ctx.db.get("User", ctx.auth.userId);
12
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
13
+ throw ctx.error("POLICY_DENIED", "Only the owner can moderate submissions.");
14
+ }
15
+ const sub = (await ctx.db.get("Submission", args.submissionId)) as { id: string } | null;
16
+ if (!sub) throw ctx.error("NOT_FOUND", "Submission not found.");
17
+ await ctx.db.unsafe.update("Submission", args.submissionId, { status: "rejected" });
18
+ return { ok: true };
19
+ },
20
+ });
@@ -0,0 +1,33 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { siteConfig } from "../lib/site.config";
3
+
4
+ // seedListings — load the starter directory from config on first visit
5
+ // (idempotent). The browse island calls this on mount; once any Listing exists
6
+ // it's a no-op, so it's safe to call on every load. A lock keeps two concurrent
7
+ // first-visits from double-seeding.
8
+ //
9
+ // Public so an anonymous first visitor seeds the directory — it only writes the
10
+ // config's PII-free starter entries, never reads or returns anything sensitive.
11
+ export default mutation<Record<string, never>, { seeded: boolean; count: number }>({
12
+ auth: "public",
13
+ async handler(ctx) {
14
+ await ctx.db.advisoryLock("directory_seed_listings");
15
+ const existing = await ctx.db.unsafe.list("Listing");
16
+ if (existing.length > 0) return { seeded: false, count: existing.length };
17
+
18
+ for (const l of siteConfig.seedListings) {
19
+ await ctx.db.unsafe.insert("Listing", {
20
+ name: l.name,
21
+ tagline: l.tagline,
22
+ url: l.url,
23
+ category: l.category,
24
+ tags: l.tags ?? null,
25
+ description: l.description ?? null,
26
+ votes: l.votes ?? 0,
27
+ featured: l.featured ?? false,
28
+ createdAt: new Date().toISOString(),
29
+ });
30
+ }
31
+ return { seeded: true, count: siteConfig.seedListings.length };
32
+ },
33
+ });
@@ -0,0 +1,29 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { SubmissionRow, OwnerSubmissionsResult } from "../lib/directory";
4
+
5
+ // submissionsForOwner — the owner's moderation queue, INCLUDING the submitter's
6
+ // name + email. The one function allowed to return that PII, gated to the
7
+ // configured owner (PYLON_OWNER_EMAIL via ctx.env). A query has no `ctx.error`,
8
+ // so a non-owner gets `{ authorized: false }` and NO data.
9
+ //
10
+ // The dashboard calls it with `callFn` and re-fetches whenever the public
11
+ // Listing set changes (approving a submission creates a Listing) — so the queue
12
+ // refreshes without a reload, while submitter contact details never sync.
13
+ export default query({
14
+ auth: "user",
15
+ async handler(ctx): Promise<OwnerSubmissionsResult> {
16
+ const me = await ctx.db.get("User", ctx.auth.userId);
17
+ const email = (me?.email as string | undefined) ?? null;
18
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
19
+ return { authorized: false };
20
+ }
21
+
22
+ const rows = (await ctx.db.unsafe.list("Submission")) as unknown as SubmissionRow[];
23
+ const submissions = rows
24
+ .map((r) => ({ ...r }))
25
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
26
+
27
+ return { authorized: true, submissions };
28
+ },
29
+ });