@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
@@ -3,10 +3,12 @@
3
3
  import React, { useEffect, useMemo, useState } from "react";
4
4
  import { db, callFn } from "@pylonsync/react";
5
5
  import { EnsureGuest } from "@pylonsync/client";
6
+ import { ArrowUpRight, ChevronUp } from "lucide-react";
6
7
  import { siteConfig } from "@/lib/site.config";
8
+ import { LINK } from "@/components/marketing";
7
9
  import { parseTags, type ListingRow } from "@/lib/directory";
8
10
 
9
- // The browse experience — the FTS showcase + the realtime heart of the template.
11
+ // The browse island: search box, category rail, and the list.
10
12
  // `db.useSearch("Listing", …)` is a LIVE faceted full-text search: it re-runs
11
13
  // the moment the box changes AND whenever the Listing table is written, so the
12
14
  // vote counts tick up across every open tab the instant anyone upvotes (the
@@ -21,6 +23,8 @@ const SORTS: { label: string; value: [string, "asc" | "desc"] }[] = [
21
23
  { label: "Newest", value: ["createdAt", "desc"] },
22
24
  ];
23
25
 
26
+ const MONO_SMALL = "font-mono text-[12px]";
27
+
24
28
  export function DirectoryBrowse() {
25
29
  return (
26
30
  <EnsureGuest fallback={<BrowseSkeleton />}>
@@ -60,87 +64,84 @@ function BrowseInner() {
60
64
  [categoryCounts],
61
65
  );
62
66
 
67
+ const facetLinks = (
68
+ <>
69
+ <FacetLink label="All" count={search.total} active={category === null} onClick={() => setCategory(null)} />
70
+ {categories.map((c) => (
71
+ <FacetLink
72
+ key={c}
73
+ label={c}
74
+ count={categoryCounts[c]}
75
+ active={category === c}
76
+ onClick={() => setCategory(category === c ? null : c)}
77
+ />
78
+ ))}
79
+ </>
80
+ );
81
+
63
82
  return (
64
83
  <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>
84
+ <input
85
+ value={query}
86
+ onChange={(e) => setQuery(e.target.value)}
87
+ placeholder={siteConfig.intro.searchPlaceholder}
88
+ aria-label="Search the directory"
89
+ className="h-10 w-full border border-zinc-300 bg-white px-3 font-mono text-[13px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-brand"
90
+ />
93
91
 
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>
92
+ <div className="mt-6 grid gap-8 lg:grid-cols-[180px_1fr]">
93
+ {/* Category rail. Live counts per category come from the search. On
94
+ narrow screens the same links run in a horizontal row. */}
95
+ <aside>
96
+ <p className="border-b border-zinc-200 pb-2 text-[12px] font-medium uppercase tracking-wide text-zinc-500">
97
+ Category
98
+ </p>
99
+ <ul className="mt-2 flex gap-x-4 gap-y-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">{facetLinks}</ul>
115
100
  </aside>
116
101
 
117
102
  {/* Results */}
118
103
  <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"}
104
+ <div className="flex items-center justify-between border-b border-zinc-200 pb-2 text-[13px] text-zinc-500">
105
+ <span className={MONO_SMALL}>
106
+ {search.total} {search.total === 1 ? "tool" : "tools"}
123
107
  {category ? <> in {category}</> : null}
124
- {debounced ? <> matching “{debounced}”</> : null}
108
+ {debounced ? <> matching "{debounced}"</> : null}
109
+ </span>
110
+ <span className="flex items-center gap-3">
111
+ {SORTS.map((s, i) => (
112
+ <button
113
+ key={s.label}
114
+ type="button"
115
+ onClick={() => setSortIdx(i)}
116
+ aria-pressed={i === sortIdx}
117
+ className={i === sortIdx ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900"}
118
+ >
119
+ {s.label}
120
+ </button>
121
+ ))}
125
122
  </span>
126
- {category ? (
127
- <button type="button" onClick={() => setCategory(null)} className="text-brand hover:underline">
128
- Clear filter
129
- </button>
130
- ) : null}
131
123
  </div>
132
124
 
133
125
  {search.loading && search.hits.length === 0 ? (
134
126
  <BrowseSkeleton bare />
135
127
  ) : 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>.
128
+ <p className="py-10 text-[14px] text-zinc-600">
129
+ No tools match.{" "}
130
+ {category ? (
131
+ <button type="button" onClick={() => setCategory(null)} className={LINK}>
132
+ Clear the category filter
133
+ </button>
134
+ ) : (
135
+ <a href="/submit" className={LINK}>
136
+ Submit one
137
+ </a>
138
+ )}
139
+ .
139
140
  </p>
140
141
  ) : (
141
- <ul className="space-y-3">
142
+ <ul>
142
143
  {search.hits.map((l) => (
143
- <ListingCard key={l.id} listing={l} />
144
+ <ListingRowItem key={l.id} listing={l} />
144
145
  ))}
145
146
  </ul>
146
147
  )}
@@ -150,7 +151,7 @@ function BrowseInner() {
150
151
  );
151
152
  }
152
153
 
153
- function FacetItem({
154
+ function FacetLink({
154
155
  label,
155
156
  count,
156
157
  active,
@@ -162,67 +163,49 @@ function FacetItem({
162
163
  onClick: () => void;
163
164
  }) {
164
165
  return (
165
- <li>
166
+ <li className="shrink-0">
166
167
  <button
167
168
  type="button"
168
169
  onClick={onClick}
170
+ aria-pressed={active}
169
171
  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
+ "flex w-full items-baseline gap-2 py-0.5 text-left text-[14px] lg:justify-between " +
173
+ (active ? "font-medium text-zinc-900" : "text-brand hover:underline")
172
174
  }
173
175
  >
174
176
  <span>{label}</span>
175
- <span className="tabular-nums text-[12px] text-zinc-400">{count ?? 0}</span>
177
+ <span className={`${MONO_SMALL} text-zinc-400`}>{count ?? 0}</span>
176
178
  </button>
177
179
  </li>
178
180
  );
179
181
  }
180
182
 
181
- function ListingCard({ listing }: { listing: ListingRow }) {
183
+ function ListingRowItem({ listing }: { listing: ListingRow }) {
182
184
  const tags = parseTags(listing.tags);
183
185
  return (
184
- <li className="flex items-start gap-4 rounded-2xl border border-zinc-200 bg-white p-4 transition-shadow hover:shadow-sm">
186
+ <li className="grid grid-cols-[44px_1fr_20px] items-start gap-3 border-b border-zinc-200 py-3">
185
187
  <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
- >
188
+ <div className="min-w-0">
189
+ <div className="flex flex-wrap items-baseline gap-x-2">
190
+ <a href={listing.url} target="_blank" rel="noopener noreferrer" className="text-[14px] font-semibold text-zinc-900 hover:text-brand">
194
191
  {listing.name}
195
192
  </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>
193
+ <span className={`${MONO_SMALL} text-zinc-400`}>{listing.category}</span>
194
+ {listing.featured ? <span className={`${MONO_SMALL} text-brand`}>featured</span> : null}
204
195
  </div>
205
- <p className="mt-1 text-[14px] leading-relaxed text-zinc-600">{listing.tagline}</p>
196
+ <p className="mt-0.5 text-[14px] leading-snug text-zinc-600">{listing.tagline}</p>
206
197
  {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>
198
+ <p className={`${MONO_SMALL} mt-1 text-zinc-400`}>{tags.join(" ")}</p>
214
199
  ) : null}
215
200
  </div>
216
201
  <a
217
202
  href={listing.url}
218
203
  target="_blank"
219
204
  rel="noopener noreferrer"
220
- aria-label={`Visit ${listing.name}`}
221
- className="mt-1 shrink-0 text-zinc-300 transition-colors hover:text-brand"
205
+ aria-label={`Open ${listing.name}`}
206
+ className="mt-0.5 text-zinc-400 hover:text-brand"
222
207
  >
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>
208
+ <ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
226
209
  </a>
227
210
  </li>
228
211
  );
@@ -267,49 +250,28 @@ function VoteButton({ listingId, votes }: { listingId: string; votes: number })
267
250
  type="button"
268
251
  onClick={onVote}
269
252
  disabled={voted}
270
- aria-label="Upvote"
253
+ aria-label={voted ? "Upvoted" : "Upvote"}
254
+ aria-pressed={voted}
271
255
  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")
256
+ "flex w-11 flex-col items-center py-0.5 font-mono text-[13px] tabular-nums " +
257
+ (voted ? "text-brand" : "text-zinc-500 hover:text-brand")
276
258
  }
277
259
  >
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>
260
+ <span>{votes}</span>
261
+ <ChevronUp size={14} strokeWidth={2} aria-hidden />
282
262
  </button>
283
263
  );
284
264
  }
285
265
 
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
266
  function BrowseSkeleton({ bare }: { bare?: boolean }) {
305
267
  const list = (
306
- <ul className="space-y-3">
268
+ <ul>
307
269
  {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" />
270
+ <li key={i} className="grid grid-cols-[44px_1fr_20px] gap-3 border-b border-zinc-200 py-3">
271
+ <div className="mx-auto h-8 w-6 animate-pulse bg-zinc-100" />
272
+ <div className="space-y-2 py-0.5">
273
+ <div className="h-3.5 w-1/3 animate-pulse bg-zinc-100" />
274
+ <div className="h-3 w-2/3 animate-pulse bg-zinc-100" />
313
275
  </div>
314
276
  </li>
315
277
  ))}
@@ -318,9 +280,9 @@ function BrowseSkeleton({ bare }: { bare?: boolean }) {
318
280
  if (bare) return list;
319
281
  return (
320
282
  <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" />
283
+ <div className="h-10 w-full animate-pulse border border-zinc-200 bg-zinc-50" />
284
+ <div className="mt-6 grid gap-8 lg:grid-cols-[180px_1fr]">
285
+ <div className="hidden h-40 animate-pulse bg-zinc-50 lg:block" />
324
286
  {list}
325
287
  </div>
326
288
  </div>
@@ -1,71 +1,56 @@
1
1
  import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
3
  import { siteConfig } from "@/lib/site.config";
4
+ import { WRAP } from "@/components/marketing";
4
5
 
5
6
  // `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
6
7
  // URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps
7
- // only the pages inside the group — a slim nav up top and a footer below,
8
- // both driven by lib/site.config.ts. Routes outside the group (/login,
9
- // /dashboard) render bare in the root shell. `auth.user_id` is resolved
10
- // server-side from the session cookie before any HTML is sent, so the nav
11
- // shows "Dashboard" once the owner is signed in and "Sign in" otherwise —
12
- // no flash, no client fetch.
8
+ // only the pages inside the group: a one-line nav and a footer, both driven by
9
+ // lib/site.config.ts. Routes outside the group (/login, /dashboard) render bare
10
+ // in the root shell. `auth.user_id` is resolved server-side from the session
11
+ // cookie before any HTML is sent, so the nav shows "Dashboard" once the owner
12
+ // is signed in and "Sign in" otherwise.
13
13
  interface LayoutProps {
14
14
  children: React.ReactNode;
15
15
  auth: PageAuth;
16
16
  }
17
17
 
18
+ const NAV_LINK = "text-[14px] text-zinc-600 hover:text-zinc-900";
19
+
18
20
  export default function MarketingLayout({ children, auth }: LayoutProps) {
19
- // A guest session (minted by <EnsureGuest> for the live counter) has a
20
- // `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
21
- // so it shouldn't flip the nav to "Dashboard".
21
+ // A guest session (minted by <EnsureGuest> for the live search) has a
22
+ // `guest_…` user id. That is an anonymous visitor, not the signed-in owner.
22
23
  const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
23
- const { brand } = siteConfig;
24
+ const { brand, intro } = siteConfig;
24
25
 
25
26
  return (
26
27
  <>
27
- <header className="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/85 backdrop-blur">
28
- <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
29
- <Link href="/" className="flex items-center gap-2">
30
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
31
- {brand.letter}
32
- </span>
33
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
34
- {brand.name}
35
- </span>
28
+ <header className="border-b border-zinc-200 bg-white">
29
+ <div className={`${WRAP} flex h-12 items-center justify-between`}>
30
+ <Link href="/" className="text-[15px] font-semibold text-zinc-900">
31
+ {brand.name}
36
32
  </Link>
37
- <nav className="flex items-center gap-1 sm:gap-2">
38
- <a href="/#browse" className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex">
33
+ <nav className="flex items-center gap-5">
34
+ <a href="/#browse" className={NAV_LINK}>
39
35
  Browse
40
36
  </a>
37
+ <Link href="/submit" className={NAV_LINK}>
38
+ {intro.ctaLabel}
39
+ </Link>
41
40
  {signedIn ? (
42
- <Link
43
- href="/dashboard"
44
- className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
45
- >
41
+ <Link href="/dashboard" className={NAV_LINK}>
46
42
  Dashboard
47
43
  </Link>
48
44
  ) : (
49
- <>
50
- <Link
51
- href="/login"
52
- className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
53
- >
54
- Sign in
55
- </Link>
56
- <Link
57
- href="/submit"
58
- className="inline-flex items-center rounded-full bg-brand px-3.5 py-1.5 text-[13px] font-medium text-white transition-opacity hover:opacity-90"
59
- >
60
- {siteConfig.hero.ctaLabel}
61
- </Link>
62
- </>
45
+ <Link href="/login" className={NAV_LINK}>
46
+ Sign in
47
+ </Link>
63
48
  )}
64
49
  </nav>
65
50
  </div>
66
51
  </header>
67
52
 
68
- <main className="flex-1">{children}</main>
53
+ <main className="flex-1 bg-white">{children}</main>
69
54
 
70
55
  <SiteFooter />
71
56
  </>
@@ -75,57 +60,21 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
75
60
  function SiteFooter() {
76
61
  const { brand } = siteConfig;
77
62
  return (
78
- <footer className="border-t border-zinc-200/70 bg-white">
79
- <div className="mx-auto max-w-5xl px-6 py-12">
80
- <div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
81
- <div className="max-w-sm">
82
- <Link href="/" className="inline-flex items-center gap-2">
83
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
84
- {brand.letter}
85
- </span>
86
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
87
- {brand.name}
88
- </span>
89
- </Link>
90
- <p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
91
- {brand.footerBlurb}
92
- </p>
93
- </div>
94
- <div className="flex items-center gap-4">
95
- {brand.socials.map((s) => (
96
- <a
97
- key={s.label}
98
- href={s.href}
99
- aria-label={s.label}
100
- className="text-zinc-400 transition-colors hover:text-zinc-900"
101
- >
102
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
103
- <path d={s.path} />
104
- </svg>
105
- </a>
106
- ))}
107
- <a
108
- href={`mailto:${brand.email}`}
109
- aria-label="Email"
110
- className="text-zinc-400 transition-colors hover:text-zinc-900"
111
- >
112
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
113
- <rect x="3" y="5" width="18" height="14" rx="2" />
114
- <path d="m3 7 9 6 9-6" />
115
- </svg>
63
+ <footer className="border-t border-zinc-200 bg-white">
64
+ <div className={`${WRAP} flex flex-col gap-3 py-8 text-[13px] text-zinc-500 sm:flex-row sm:items-center sm:justify-between`}>
65
+ <p className="max-w-md">{brand.footerBlurb}</p>
66
+ <div className="flex items-center gap-4">
67
+ {brand.socials.map((s) => (
68
+ <a key={s.label} href={s.href} className="hover:text-zinc-900">
69
+ {s.label}
116
70
  </a>
117
- </div>
118
- </div>
119
- <div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
120
- <span>
71
+ ))}
72
+ <a href={`mailto:${brand.email}`} className="hover:text-zinc-900">
73
+ Email
74
+ </a>
75
+ <span className="font-mono text-[12px] text-zinc-400">
121
76
  © {new Date().getFullYear()} {brand.copyrightName}
122
77
  </span>
123
- <span>
124
- Built with{" "}
125
- <a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
126
- Pylon
127
- </a>
128
- </span>
129
78
  </div>
130
79
  </div>
131
80
  </footer>
@@ -1,23 +1,24 @@
1
1
  import React from "react";
2
2
  import { Link, type NotFoundProps } from "@pylonsync/react";
3
+ import { LINK, WRAP } from "@/components/marketing";
3
4
 
4
5
  // `(marketing)/not-found.tsx` → rendered at HTTP 404 for any unmatched URL
5
6
  // (and when a page calls `response.notFound()`). It lives inside the
6
7
  // `(marketing)` group on purpose: a group segment adds no URL prefix, so this
7
- // is still the root 404 boundary, but it wraps in the marketing layout — a
8
+ // is still the root 404 boundary, but it wraps in the marketing layout, so a
8
9
  // missing URL gets the site nav + footer instead of a bare page. Hydrated, so
9
10
  // the link is a client nav.
10
11
  export default function NotFound(_props: NotFoundProps) {
11
12
  return (
12
- <div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
13
- <h1 className="text-3xl font-semibold tracking-tight">404</h1>
14
- <p className="mt-2 text-zinc-500">We couldn&apos;t find that page.</p>
15
- <Link
16
- href="/"
17
- 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"
18
- >
19
- Back home
20
- </Link>
13
+ <div className={`${WRAP} py-20`}>
14
+ <p className="font-mono text-[12px] text-zinc-400">404</p>
15
+ <h1 className="font-display mt-2 text-[22px] text-zinc-900">Page not found</h1>
16
+ <p className="mt-2 text-[14px] text-zinc-600">
17
+ There is no page at this address.{" "}
18
+ <Link href="/" className={LINK}>
19
+ Back to the directory
20
+ </Link>
21
+ </p>
21
22
  </div>
22
23
  );
23
24
  }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link, type Metadata } from "@pylonsync/react";
2
+ import { type Metadata } from "@pylonsync/react";
3
3
  import { WRAP } from "@/components/marketing";
4
4
  import { DirectoryBrowse } from "./directory-browse";
5
5
  import { siteConfig } from "@/lib/site.config";
@@ -10,41 +10,20 @@ export const metadata: Metadata = {
10
10
  openGraph: { title: siteConfig.seo.title, description: siteConfig.seo.description, type: "website" },
11
11
  };
12
12
 
13
- // `app/page.tsx` → `/`. Server-rendered hero + a client island (#browse) that
14
- // runs a LIVE faceted full-text search over the public Listing table. Copy
15
- // comes from siteConfig; the listings seed on first visit. Doesn't read `auth`,
16
- // so the public page stays cacheable.
13
+ // `app/page.tsx` → `/`. A short server-rendered intro (brand name and one
14
+ // line), then the client island (#browse): the search box, the category rail,
15
+ // and the list, all driven by a LIVE faceted full-text search over the public
16
+ // Listing table. Copy comes from siteConfig; the listings seed on first visit.
17
+ // Doesn't read `auth`, so the public page stays cacheable.
17
18
  export default function LandingPage() {
18
- const { hero, browse } = siteConfig;
19
+ const { brand, intro } = siteConfig;
19
20
  return (
20
- <div className="bg-white text-zinc-900">
21
- {/* HERO */}
22
- <section className={`${WRAP} pt-16 pb-8 sm:pt-20`}>
23
- <div className="max-w-2xl">
24
- <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">{hero.tagline}</p>
25
- <h1 className="mt-4 text-balance text-[2.5rem] font-semibold leading-[1.04] tracking-[-0.02em] sm:text-[3.25rem]">
26
- {hero.headline}
27
- </h1>
28
- <p className="mt-5 text-[17px] leading-relaxed text-zinc-500">{hero.subcopy}</p>
29
- <div className="mt-7 flex flex-wrap items-center gap-4">
30
- <a href="#browse" className="inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90">
31
- Browse the directory
32
- </a>
33
- <Link href="/submit" className="text-sm font-medium text-zinc-700 hover:text-zinc-900">
34
- {hero.ctaLabel} →
35
- </Link>
36
- </div>
37
- </div>
38
- </section>
39
-
40
- {/* BROWSE — live faceted search */}
41
- <section id="browse" className={`${WRAP} pb-20 pt-4`}>
42
- <div className="mb-6">
43
- <p className="font-mono text-[11px] uppercase tracking-[0.14em] text-brand">{browse.eyebrow}</p>
44
- <h2 className="mt-3 text-2xl font-semibold tracking-[-0.02em]">{browse.headline}</h2>
45
- </div>
21
+ <div className={`${WRAP} pb-20 pt-8`}>
22
+ <h1 className="font-display text-[22px] leading-tight text-zinc-900">{brand.name}</h1>
23
+ <p className="mt-1 max-w-2xl text-[14px] leading-relaxed text-zinc-600">{intro.description}</p>
24
+ <div id="browse" className="mt-5">
46
25
  <DirectoryBrowse />
47
- </section>
26
+ </div>
48
27
  </div>
49
28
  );
50
29
  }
@@ -5,26 +5,25 @@ import { SubmitForm } from "../submit-form";
5
5
  import { siteConfig } from "@/lib/site.config";
6
6
 
7
7
  export const metadata: Metadata = {
8
- title: `Submit a tool — ${siteConfig.brand.name}`,
8
+ title: `Submit a tool, ${siteConfig.brand.name}`,
9
9
  description: siteConfig.submit.subcopy,
10
10
  };
11
11
 
12
12
  // `app/submit/page.tsx` → `/submit`. Server-rendered shell; the form is a client
13
13
  // island that calls the public submitListing mutation. Submissions land in the
14
- // owner's moderation queue (the deny-all Submission table) — not the public
15
- // directory — until approved.
14
+ // owner's moderation queue (the deny-all Submission table), not the public
15
+ // directory, until approved.
16
16
  export default function SubmitPage() {
17
17
  const { submit } = siteConfig;
18
18
  return (
19
- <section className={`${WRAP} py-16`}>
20
- <div className="mx-auto max-w-2xl">
21
- <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">{submit.eyebrow}</p>
22
- <h1 className="mt-4 text-balance text-3xl font-semibold tracking-[-0.02em]">{submit.headline}</h1>
23
- <p className="mt-4 text-[15px] leading-relaxed text-zinc-500">{submit.subcopy}</p>
24
- <div className="mt-8">
19
+ <div className={`${WRAP} pb-20 pt-8`}>
20
+ <div className="max-w-xl">
21
+ <h1 className="font-display text-[22px] leading-tight text-zinc-900">{submit.headline}</h1>
22
+ <p className="mt-1 text-[14px] leading-relaxed text-zinc-600">{submit.subcopy}</p>
23
+ <div className="mt-6">
25
24
  <SubmitForm />
26
25
  </div>
27
26
  </div>
28
- </section>
27
+ </div>
29
28
  );
30
29
  }