@pylonsync/create-pylon 0.3.274 → 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,205 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import { parseTags, type SubmissionRow, type OwnerSubmissionsResult } from "@/lib/directory";
7
+
8
+ // The curator's moderation queue. Liveness rides the public Listing table:
9
+ // `db.useQuery("Listing")` re-renders when listings change (approving a
10
+ // submission creates one), and that same signal re-fetches the owner-gated
11
+ // `submissionsForOwner` — so the queue stays current without a reload, while
12
+ // submitter PII only ever travels through the gated call.
13
+ export function DirectoryDashboard({ userEmail }: { userEmail: string }) {
14
+ const { data: listings } = db.useQuery<{ id: string }>("Listing");
15
+ const liveCount = listings.length;
16
+
17
+ const [submissions, setSubmissions] = useState<SubmissionRow[] | null>(null);
18
+ const [denied, setDenied] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [busyId, setBusyId] = useState<string | null>(null);
21
+
22
+ async function load() {
23
+ try {
24
+ const r = await callFn<OwnerSubmissionsResult>("submissionsForOwner", {});
25
+ if (!r.authorized) setDenied(true);
26
+ else {
27
+ setSubmissions(r.submissions);
28
+ setDenied(false);
29
+ setError(null);
30
+ }
31
+ } catch (e) {
32
+ setError(e instanceof Error ? e.message : String(e));
33
+ }
34
+ }
35
+
36
+ useEffect(() => {
37
+ void load();
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [liveCount]);
40
+
41
+ async function act(id: string, fn: "approveSubmission" | "rejectSubmission") {
42
+ setBusyId(id);
43
+ try {
44
+ await callFn(fn, { submissionId: id });
45
+ await load();
46
+ } finally {
47
+ setBusyId(null);
48
+ }
49
+ }
50
+
51
+ if (denied) return <OwnerOnly email={userEmail} />;
52
+ if (error) {
53
+ return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
54
+ }
55
+ if (!submissions) return <Skeleton />;
56
+
57
+ const pending = submissions.filter((s) => s.status === "new");
58
+ const approved = submissions.filter((s) => s.status === "approved").length;
59
+
60
+ return (
61
+ <div className="space-y-8">
62
+ <div>
63
+ <h1 className="text-xl font-semibold tracking-tight">Submissions</h1>
64
+ <p className="mt-1 text-sm text-zinc-500">
65
+ Live — new submissions land here; approving one publishes it to the directory instantly.
66
+ </p>
67
+ </div>
68
+
69
+ <div className="grid gap-4 sm:grid-cols-3">
70
+ <Stat label="Pending" value={String(pending.length)} />
71
+ <Stat label="Approved" value={String(approved)} />
72
+ <Stat label="Live listings" value={String(liveCount)} />
73
+ </div>
74
+
75
+ <div className="rounded-xl border border-zinc-200 bg-white">
76
+ <div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
77
+ Queue <span className="font-normal text-zinc-400">({submissions.length})</span>
78
+ </div>
79
+ {submissions.length === 0 ? (
80
+ <p className="p-8 text-center text-sm text-zinc-500">No submissions yet.</p>
81
+ ) : (
82
+ <ul className="divide-y divide-zinc-100">
83
+ {submissions.map((s) => (
84
+ <li key={s.id} className="px-4 py-3.5">
85
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
86
+ <div className="min-w-0 flex-1">
87
+ <div className="flex flex-wrap items-center gap-2">
88
+ <a href={s.url} target="_blank" rel="noopener noreferrer" className="text-[14px] font-medium text-zinc-900 hover:text-brand">
89
+ {s.name}
90
+ </a>
91
+ <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-500">{s.category}</span>
92
+ <StatusBadge status={s.status} />
93
+ </div>
94
+ {s.tagline ? <p className="mt-1 text-[13.5px] text-zinc-600">{s.tagline}</p> : null}
95
+ <div className="mt-1 truncate text-[12px] text-zinc-400">
96
+ {s.submitterName} · {s.submitterEmail}
97
+ {parseTags(s.tags).length ? ` · ${parseTags(s.tags).map((t) => "#" + t).join(" ")}` : ""}
98
+ </div>
99
+ </div>
100
+ {s.status === "new" ? (
101
+ <div className="flex items-center gap-2">
102
+ <button
103
+ type="button"
104
+ disabled={busyId === s.id}
105
+ onClick={() => act(s.id, "approveSubmission")}
106
+ className="rounded-md bg-brand px-3 py-1.5 text-[12.5px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
107
+ >
108
+ {busyId === s.id ? "…" : "Approve"}
109
+ </button>
110
+ <button
111
+ type="button"
112
+ disabled={busyId === s.id}
113
+ onClick={() => act(s.id, "rejectSubmission")}
114
+ className="rounded-md border border-zinc-300 px-3 py-1.5 text-[12.5px] font-medium text-zinc-600 transition-colors hover:border-red-300 hover:text-red-600 disabled:opacity-50"
115
+ >
116
+ Reject
117
+ </button>
118
+ </div>
119
+ ) : null}
120
+ </div>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function Stat({ label, value }: { label: string; value: string }) {
131
+ return (
132
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
133
+ <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</div>
134
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">{value}</div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function StatusBadge({ status }: { status: string }) {
140
+ const tone =
141
+ status === "approved"
142
+ ? "bg-green-50 text-green-700"
143
+ : status === "rejected"
144
+ ? "bg-zinc-100 text-zinc-400"
145
+ : "bg-amber-50 text-amber-700"; // new
146
+ const label = status === "new" ? "pending" : status;
147
+ return <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + tone}>{label}</span>;
148
+ }
149
+
150
+ function OwnerOnly({ email }: { email: string }) {
151
+ return (
152
+ <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
153
+ <h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
154
+ <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
155
+ You&apos;re signed in as <span className="font-medium text-zinc-700">{email || "this account"}</span>.
156
+ Only the curator can review submissions. Set{" "}
157
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">PYLON_OWNER_EMAIL={email || "you@yourdirectory.com"}</code>{" "}
158
+ in your <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>, restart, and reload —
159
+ or sign in with the owner account.
160
+ </p>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ export function UserMenu({ email }: { email: string }) {
166
+ const { signOut } = useAuth();
167
+ const initial = (email.trim()[0] || "?").toUpperCase();
168
+ async function onSignOut() {
169
+ await signOut();
170
+ window.location.assign("/");
171
+ }
172
+ return (
173
+ <details className="group relative">
174
+ <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
175
+ {initial}
176
+ </summary>
177
+ <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
178
+ <div className="border-b border-zinc-100 px-3 py-2">
179
+ <div className="truncate text-[13px] font-medium text-zinc-900">{email || "Signed in"}</div>
180
+ </div>
181
+ <button
182
+ type="button"
183
+ onClick={onSignOut}
184
+ className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
185
+ >
186
+ Sign out
187
+ </button>
188
+ </div>
189
+ </details>
190
+ );
191
+ }
192
+
193
+ function Skeleton() {
194
+ return (
195
+ <div className="space-y-8">
196
+ <div className="h-6 w-32 animate-pulse rounded bg-zinc-100" />
197
+ <div className="grid gap-4 sm:grid-cols-3">
198
+ {[0, 1, 2].map((i) => (
199
+ <div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
200
+ ))}
201
+ </div>
202
+ <div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,70 @@
1
+ import React, { use } from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { siteConfig } from "@/lib/site.config";
4
+ import { UserMenu, DirectoryDashboard } from "./dashboard-client";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Dashboard — ${siteConfig.brand.name}`,
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate only — any
12
+ // signed-in user can load the shell. The OWNER gate (a directory is single-
13
+ // tenant: only PYLON_OWNER_EMAIL may see submissions) lives in the
14
+ // `submissionsForOwner` function, which checks `ctx.env.PYLON_OWNER_EMAIL` — the
15
+ // authoritative server env. Non-owners get a clean "owner-only" card from the
16
+ // client when that call is denied. (Env-based config is read in the function,
17
+ // not here: an SSR page render can't reliably read arbitrary `process.env`.)
18
+ export default function DashboardPage({ auth, response, serverData }: PageProps) {
19
+ // Anonymous visitors and guest sessions (guest_… ids) get bounced to login —
20
+ // the dashboard is for the real, signed-in owner only.
21
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
22
+ response.redirect("/login");
23
+ return null;
24
+ }
25
+
26
+ // The User self-read policy lets the signed-in user read their own row → the
27
+ // email shown in the account menu (and on the owner-only card).
28
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
29
+ const email = me?.email ?? "";
30
+
31
+ return (
32
+ <Shell email={email}>
33
+ <DirectoryDashboard userEmail={email} />
34
+ </Shell>
35
+ );
36
+ }
37
+
38
+ // Dashboard chrome: a slim top bar with the logo, a link back to the public
39
+ // site, and the account menu (a client island for sign-out).
40
+ function Shell({ email, children }: { email: string; children: React.ReactNode }) {
41
+ const { brand } = siteConfig;
42
+ return (
43
+ <div className="flex min-h-screen flex-col bg-white text-zinc-900">
44
+ <header className="border-b border-zinc-200">
45
+ <div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-6">
46
+ <div className="flex items-center gap-2">
47
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
48
+ {brand.letter}
49
+ </span>
50
+ <span className="text-[15px] font-semibold tracking-tight">
51
+ {brand.name} <span className="text-zinc-400">/ curator</span>
52
+ </span>
53
+ </div>
54
+ <div className="flex items-center gap-4">
55
+ <Link
56
+ href="/"
57
+ className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
58
+ >
59
+ View site ↗
60
+ </Link>
61
+ <UserMenu email={email} />
62
+ </div>
63
+ </div>
64
+ </header>
65
+ <main className="flex-1">
66
+ <div className="mx-auto max-w-4xl px-6 py-8">{children}</div>
67
+ </main>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,328 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { EnsureGuest } from "@pylonsync/client";
6
+ import { siteConfig } from "@/lib/site.config";
7
+ import { parseTags, type ListingRow } from "@/lib/directory";
8
+
9
+ // The browse experience — the FTS showcase + the realtime heart of the template.
10
+ // `db.useSearch("Listing", …)` is a LIVE faceted full-text search: it re-runs
11
+ // the moment the box changes AND whenever the Listing table is written, so the
12
+ // vote counts tick up across every open tab the instant anyone upvotes (the
13
+ // `upvote` mutation is the write). No search service, no polling.
14
+ //
15
+ // Wrapped in <EnsureGuest> so the sync connection (which makes useSearch live)
16
+ // is established for anonymous visitors, and so seedListings can run on first
17
+ // load. The guest session holds no PII; Listings carry none.
18
+
19
+ const SORTS: { label: string; value: [string, "asc" | "desc"] }[] = [
20
+ { label: "Top voted", value: ["votes", "desc"] },
21
+ { label: "Newest", value: ["createdAt", "desc"] },
22
+ ];
23
+
24
+ export function DirectoryBrowse() {
25
+ return (
26
+ <EnsureGuest fallback={<BrowseSkeleton />}>
27
+ <BrowseInner />
28
+ </EnsureGuest>
29
+ );
30
+ }
31
+
32
+ function BrowseInner() {
33
+ const [query, setQuery] = useState("");
34
+ const [debounced, setDebounced] = useState("");
35
+ const [category, setCategory] = useState<string | null>(null);
36
+ const [sortIdx, setSortIdx] = useState(0);
37
+
38
+ // Seed the directory from config on first visit (idempotent server-side).
39
+ useEffect(() => {
40
+ void callFn("seedListings", {});
41
+ }, []);
42
+
43
+ // Debounce the query so we're not firing a search on every keystroke.
44
+ useEffect(() => {
45
+ const t = setTimeout(() => setDebounced(query.trim()), 180);
46
+ return () => clearTimeout(t);
47
+ }, [query]);
48
+
49
+ const search = db.useSearch<ListingRow>("Listing", {
50
+ query: debounced,
51
+ filters: category ? { category } : {},
52
+ facets: ["category"],
53
+ sort: SORTS[sortIdx].value,
54
+ pageSize: 100,
55
+ });
56
+
57
+ const categoryCounts = search.facetCounts.category ?? {};
58
+ const categories = useMemo(
59
+ () => Object.keys(categoryCounts).sort((a, b) => categoryCounts[b] - categoryCounts[a]),
60
+ [categoryCounts],
61
+ );
62
+
63
+ return (
64
+ <div>
65
+ {/* Search bar */}
66
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
67
+ <div className="relative flex-1">
68
+ <SearchIcon />
69
+ <input
70
+ value={query}
71
+ onChange={(e) => setQuery(e.target.value)}
72
+ placeholder={siteConfig.hero.searchPlaceholder}
73
+ aria-label="Search the directory"
74
+ className="h-11 w-full rounded-full border border-zinc-300 bg-white pl-10 pr-4 text-[15px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
75
+ />
76
+ </div>
77
+ <div className="flex items-center gap-1 rounded-full border border-zinc-200 bg-white p-1">
78
+ {SORTS.map((s, i) => (
79
+ <button
80
+ key={s.label}
81
+ type="button"
82
+ onClick={() => setSortIdx(i)}
83
+ className={
84
+ "rounded-full px-3 py-1.5 text-[13px] font-medium transition-colors " +
85
+ (i === sortIdx ? "bg-brand text-white" : "text-zinc-600 hover:text-zinc-900")
86
+ }
87
+ >
88
+ {s.label}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ </div>
93
+
94
+ <div className="mt-6 grid gap-8 lg:grid-cols-[200px_1fr]">
95
+ {/* Facet sidebar — live counts per category from the search */}
96
+ <aside className="hidden lg:block">
97
+ <h3 className="font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400">Category</h3>
98
+ <ul className="mt-3 space-y-0.5">
99
+ <FacetItem
100
+ label="All"
101
+ count={search.total}
102
+ active={category === null}
103
+ onClick={() => setCategory(null)}
104
+ />
105
+ {categories.map((c) => (
106
+ <FacetItem
107
+ key={c}
108
+ label={c}
109
+ count={categoryCounts[c]}
110
+ active={category === c}
111
+ onClick={() => setCategory(category === c ? null : c)}
112
+ />
113
+ ))}
114
+ </ul>
115
+ </aside>
116
+
117
+ {/* Results */}
118
+ <div>
119
+ <div className="mb-3 flex items-center justify-between text-[13px] text-zinc-500">
120
+ <span>
121
+ <span className="font-medium text-zinc-900">{search.total}</span>{" "}
122
+ {search.total === 1 ? "tool" : "tools"}
123
+ {category ? <> in {category}</> : null}
124
+ {debounced ? <> matching “{debounced}”</> : null}
125
+ </span>
126
+ {category ? (
127
+ <button type="button" onClick={() => setCategory(null)} className="text-brand hover:underline">
128
+ Clear filter
129
+ </button>
130
+ ) : null}
131
+ </div>
132
+
133
+ {search.loading && search.hits.length === 0 ? (
134
+ <BrowseSkeleton bare />
135
+ ) : search.hits.length === 0 ? (
136
+ <p className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
137
+ No tools match. Try a different search — or{" "}
138
+ <a href="/submit" className="font-medium text-brand hover:underline">submit one</a>.
139
+ </p>
140
+ ) : (
141
+ <ul className="space-y-3">
142
+ {search.hits.map((l) => (
143
+ <ListingCard key={l.id} listing={l} />
144
+ ))}
145
+ </ul>
146
+ )}
147
+ </div>
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ function FacetItem({
154
+ label,
155
+ count,
156
+ active,
157
+ onClick,
158
+ }: {
159
+ label: string;
160
+ count: number;
161
+ active: boolean;
162
+ onClick: () => void;
163
+ }) {
164
+ return (
165
+ <li>
166
+ <button
167
+ type="button"
168
+ onClick={onClick}
169
+ className={
170
+ "flex w-full items-center justify-between rounded-lg px-2.5 py-1.5 text-left text-[13.5px] transition-colors " +
171
+ (active ? "bg-brand-soft font-medium text-brand" : "text-zinc-600 hover:bg-zinc-100")
172
+ }
173
+ >
174
+ <span>{label}</span>
175
+ <span className="tabular-nums text-[12px] text-zinc-400">{count ?? 0}</span>
176
+ </button>
177
+ </li>
178
+ );
179
+ }
180
+
181
+ function ListingCard({ listing }: { listing: ListingRow }) {
182
+ const tags = parseTags(listing.tags);
183
+ return (
184
+ <li className="flex items-start gap-4 rounded-2xl border border-zinc-200 bg-white p-4 transition-shadow hover:shadow-sm">
185
+ <VoteButton listingId={listing.id} votes={listing.votes} />
186
+ <div className="min-w-0 flex-1">
187
+ <div className="flex flex-wrap items-center gap-2">
188
+ <a
189
+ href={listing.url}
190
+ target="_blank"
191
+ rel="noopener noreferrer"
192
+ className="text-[15px] font-semibold text-zinc-900 hover:text-brand"
193
+ >
194
+ {listing.name}
195
+ </a>
196
+ {listing.featured ? (
197
+ <span className="rounded-full bg-brand-soft px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-brand">
198
+ Featured
199
+ </span>
200
+ ) : null}
201
+ <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-500">
202
+ {listing.category}
203
+ </span>
204
+ </div>
205
+ <p className="mt-1 text-[14px] leading-relaxed text-zinc-600">{listing.tagline}</p>
206
+ {tags.length > 0 ? (
207
+ <div className="mt-2 flex flex-wrap gap-1.5">
208
+ {tags.map((t) => (
209
+ <span key={t} className="rounded bg-zinc-50 px-1.5 py-0.5 text-[11px] text-zinc-400">
210
+ #{t}
211
+ </span>
212
+ ))}
213
+ </div>
214
+ ) : null}
215
+ </div>
216
+ <a
217
+ href={listing.url}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ aria-label={`Visit ${listing.name}`}
221
+ className="mt-1 shrink-0 text-zinc-300 transition-colors hover:text-brand"
222
+ >
223
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
224
+ <path d="M7 17 17 7M7 7h10v10" />
225
+ </svg>
226
+ </a>
227
+ </li>
228
+ );
229
+ }
230
+
231
+ // The upvote control. Clicking calls the public `upvote` mutation; the count
232
+ // you see comes from the LIVE search, so it ticks up here and in every other
233
+ // open tab. localStorage remembers your votes so the arrow stays "voted".
234
+ function VoteButton({ listingId, votes }: { listingId: string; votes: number }) {
235
+ const [voted, setVoted] = useState(false);
236
+
237
+ useEffect(() => {
238
+ try {
239
+ setVoted(localStorage.getItem(`voted:${listingId}`) === "1");
240
+ } catch {
241
+ /* ignore */
242
+ }
243
+ }, [listingId]);
244
+
245
+ async function onVote() {
246
+ if (voted) return;
247
+ setVoted(true);
248
+ try {
249
+ localStorage.setItem(`voted:${listingId}`, "1");
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ try {
254
+ await callFn("upvote", { listingId });
255
+ } catch {
256
+ setVoted(false);
257
+ try {
258
+ localStorage.removeItem(`voted:${listingId}`);
259
+ } catch {
260
+ /* ignore */
261
+ }
262
+ }
263
+ }
264
+
265
+ return (
266
+ <button
267
+ type="button"
268
+ onClick={onVote}
269
+ disabled={voted}
270
+ aria-label="Upvote"
271
+ className={
272
+ "flex h-14 w-12 shrink-0 flex-col items-center justify-center rounded-xl border text-center transition-colors " +
273
+ (voted
274
+ ? "border-brand bg-brand-soft text-brand"
275
+ : "border-zinc-200 text-zinc-500 hover:border-brand hover:text-brand")
276
+ }
277
+ >
278
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
279
+ <path d="m18 15-6-6-6 6" />
280
+ </svg>
281
+ <span className="text-[13px] font-semibold tabular-nums">{votes}</span>
282
+ </button>
283
+ );
284
+ }
285
+
286
+ function SearchIcon() {
287
+ return (
288
+ <svg
289
+ className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-zinc-400"
290
+ viewBox="0 0 24 24"
291
+ fill="none"
292
+ stroke="currentColor"
293
+ strokeWidth="2"
294
+ strokeLinecap="round"
295
+ strokeLinejoin="round"
296
+ aria-hidden
297
+ >
298
+ <circle cx="11" cy="11" r="8" />
299
+ <path d="m21 21-4.3-4.3" />
300
+ </svg>
301
+ );
302
+ }
303
+
304
+ function BrowseSkeleton({ bare }: { bare?: boolean }) {
305
+ const list = (
306
+ <ul className="space-y-3">
307
+ {Array.from({ length: 6 }).map((_, i) => (
308
+ <li key={i} className="flex items-start gap-4 rounded-2xl border border-zinc-200 bg-white p-4">
309
+ <div className="h-14 w-12 animate-pulse rounded-xl bg-zinc-100" />
310
+ <div className="flex-1 space-y-2 py-1">
311
+ <div className="h-4 w-1/3 animate-pulse rounded bg-zinc-100" />
312
+ <div className="h-3 w-2/3 animate-pulse rounded bg-zinc-100" />
313
+ </div>
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ );
318
+ if (bare) return list;
319
+ return (
320
+ <div>
321
+ <div className="h-11 w-full animate-pulse rounded-full bg-zinc-100" />
322
+ <div className="mt-6 grid gap-8 lg:grid-cols-[200px_1fr]">
323
+ <div className="hidden h-40 animate-pulse rounded-xl bg-zinc-100 lg:block" />
324
+ {list}
325
+ </div>
326
+ </div>
327
+ );
328
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { type ErrorBoundaryProps } from "@pylonsync/react";
3
+
4
+ // `app/error.tsx` → the error boundary for this segment. Hydrated + interactive:
5
+ // `reset()` re-attempts the route. The thrown error reaches the client as
6
+ // `{ message, digest }` only — the stack stays in the dev overlay / server logs.
7
+ export default function Error({ error, reset }: ErrorBoundaryProps) {
8
+ return (
9
+ <div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
10
+ <h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
11
+ <p className="mt-2 text-zinc-500">{error.message}</p>
12
+ {error.digest ? (
13
+ <p className="mt-1 text-xs text-zinc-400">
14
+ Reference: <code>{error.digest}</code>
15
+ </p>
16
+ ) : null}
17
+ <button
18
+ type="button"
19
+ onClick={reset}
20
+ className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
21
+ >
22
+ Try again
23
+ </button>
24
+ </div>
25
+ );
26
+ }