@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.
- package/bin/create-pylon.js +1 -1
- package/package.json +1 -1
- package/templates/_root/AGENTS.md +31 -1
- package/templates/agency/AGENTS.md +1 -1
- package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
- package/templates/agency/app/(marketing)/layout.tsx +36 -64
- package/templates/agency/app/(marketing)/not-found.tsx +6 -8
- package/templates/agency/app/(marketing)/page.tsx +93 -144
- package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
- package/templates/agency/app/(marketing)/work/page.tsx +20 -17
- package/templates/agency/app/globals.css +15 -6
- package/templates/agency/app.ts +10 -0
- package/templates/agency/components/marketing.tsx +40 -104
- package/templates/agency/lib/site.config.ts +34 -52
- package/templates/agency/public/images/hero.jpg +0 -0
- package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
- package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
- package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
- package/templates/agency/public/images/work/atlas-health.jpg +0 -0
- package/templates/agency/public/images/work/cohort.jpg +0 -0
- package/templates/agency/public/images/work/ledger.jpg +0 -0
- package/templates/agency/public/images/work/vela.jpg +0 -0
- package/templates/ai-chat/AGENTS.md +1 -1
- package/templates/ai-studio/AGENTS.md +1 -1
- package/templates/barebones/AGENTS.md +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/consumer/AGENTS.md +1 -1
- package/templates/consumer/README.md +39 -23
- package/templates/consumer/app/(app)/explore/page.tsx +13 -0
- package/templates/consumer/app/(app)/layout.tsx +17 -0
- package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
- package/templates/consumer/app/(app)/page.tsx +15 -0
- package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
- package/templates/consumer/app/error.tsx +1 -1
- package/templates/consumer/app/globals.css +25 -0
- package/templates/consumer/app/layout.tsx +3 -10
- package/templates/consumer/app/llms.ts +10 -10
- package/templates/consumer/app/not-found.tsx +9 -22
- package/templates/consumer/app/robots.ts +3 -4
- package/templates/consumer/app/sitemap.ts +7 -21
- package/templates/consumer/app.ts +152 -33
- package/templates/consumer/components/social/app-chrome.tsx +109 -0
- package/templates/consumer/components/social/avatar.tsx +50 -0
- package/templates/consumer/components/social/comment-form.tsx +51 -0
- package/templates/consumer/components/social/create-post.tsx +155 -0
- package/templates/consumer/components/social/edit-profile.tsx +103 -0
- package/templates/consumer/components/social/explore-screen.tsx +23 -0
- package/templates/consumer/components/social/home-screen.tsx +161 -0
- package/templates/consumer/components/social/post-card.tsx +77 -0
- package/templates/consumer/components/social/post-grid.tsx +46 -0
- package/templates/consumer/components/social/post-parts.tsx +146 -0
- package/templates/consumer/components/social/post-screen.tsx +173 -0
- package/templates/consumer/components/social/profile-screen.tsx +146 -0
- package/templates/consumer/components/social/rich-text.tsx +22 -0
- package/templates/consumer/components/social/session.tsx +43 -0
- package/templates/consumer/components/social/use-social.ts +86 -0
- package/templates/consumer/functions/addComment.ts +27 -0
- package/templates/consumer/functions/createPost.ts +39 -0
- package/templates/consumer/functions/ensureProfile.ts +39 -0
- package/templates/consumer/functions/seedFeed.ts +38 -0
- package/templates/consumer/functions/updateProfile.ts +61 -0
- package/templates/consumer/lib/seed.ts +192 -0
- package/templates/consumer/lib/site.ts +6 -0
- package/templates/consumer/lib/social.ts +245 -0
- package/templates/consumer/tests/social.test.ts +157 -0
- package/templates/creator/AGENTS.md +1 -1
- package/templates/creator/app/(marketing)/layout.tsx +35 -69
- package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
- package/templates/creator/app/(marketing)/not-found.tsx +8 -6
- package/templates/creator/app/(marketing)/page.tsx +90 -106
- package/templates/creator/app/globals.css +14 -0
- package/templates/creator/app/layout.tsx +1 -0
- package/templates/creator/app.ts +10 -0
- package/templates/creator/components/marketing.tsx +28 -89
- package/templates/creator/lib/site.config.ts +51 -29
- package/templates/creator/public/images/headshot.jpg +0 -0
- package/templates/crm/AGENTS.md +1 -1
- package/templates/crm/lib/seed.ts +153 -21
- package/templates/directory/AGENTS.md +1 -1
- package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
- package/templates/directory/app/(marketing)/layout.tsx +37 -88
- package/templates/directory/app/(marketing)/not-found.tsx +11 -10
- package/templates/directory/app/(marketing)/page.tsx +12 -33
- package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
- package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
- package/templates/directory/app/globals.css +15 -4
- package/templates/directory/app.ts +10 -0
- package/templates/directory/components/marketing.tsx +27 -91
- package/templates/directory/lib/site.config.ts +13 -24
- package/templates/expo/mobile/apps/expo/README.md +1 -1
- package/templates/expo/mobile/apps/expo/package.json +55 -54
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +24 -6
- package/templates/helpdesk/AGENTS.md +1 -1
- package/templates/helpdesk/lib/seed.ts +188 -104
- package/templates/inventory/AGENTS.md +1 -1
- package/templates/inventory/lib/seed.ts +175 -44
- package/templates/invoices/AGENTS.md +1 -1
- package/templates/invoices/lib/seed.ts +74 -89
- package/templates/local-service/AGENTS.md +1 -1
- package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
- package/templates/local-service/app/(marketing)/layout.tsx +25 -37
- package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
- package/templates/local-service/app/(marketing)/page.tsx +145 -139
- package/templates/local-service/app/globals.css +11 -0
- package/templates/local-service/app/layout.tsx +2 -0
- package/templates/local-service/app.ts +9 -0
- package/templates/local-service/components/marketing.tsx +14 -39
- package/templates/local-service/lib/site.config.ts +16 -23
- package/templates/local-service/public/images/hero.jpg +0 -0
- package/templates/marketplace/AGENTS.md +1 -1
- package/templates/marketplace/client/SellForm.tsx +2 -0
- package/templates/projects/AGENTS.md +1 -1
- package/templates/projects/lib/seed.ts +103 -32
- package/templates/restaurant/AGENTS.md +1 -1
- package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
- package/templates/restaurant/app/(marketing)/page.tsx +146 -123
- package/templates/restaurant/app/globals.css +11 -0
- package/templates/restaurant/app/layout.tsx +1 -0
- package/templates/restaurant/app.ts +10 -0
- package/templates/restaurant/components/marketing.tsx +10 -0
- package/templates/restaurant/lib/site.config.ts +16 -14
- package/templates/restaurant/public/images/bar.jpg +0 -0
- package/templates/restaurant/public/images/branzino.jpg +0 -0
- package/templates/restaurant/public/images/hearth.jpg +0 -0
- package/templates/restaurant/public/images/hero.jpg +0 -0
- package/templates/saas/AGENTS.md +1 -1
- package/templates/shop/AGENTS.md +1 -1
- package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
- package/templates/shop/public/images/products/linen.jpg +0 -0
- package/templates/todo/AGENTS.md +1 -1
- package/templates/waitlist/AGENTS.md +1 -1
- package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
- package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
- package/templates/waitlist/app/(marketing)/page.tsx +51 -86
- package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
- package/templates/waitlist/app/globals.css +23 -0
- package/templates/waitlist/app/layout.tsx +1 -0
- package/templates/waitlist/app.ts +18 -0
- package/templates/waitlist/components/marketing.tsx +67 -81
- package/templates/waitlist/lib/site.config.ts +52 -59
- package/templates/agency/app/not-found.tsx +0 -44
- package/templates/ai-chat/app/not-found.tsx +0 -44
- package/templates/ai-studio/app/not-found.tsx +0 -44
- package/templates/consumer/app/feed-client.tsx +0 -159
- package/templates/consumer/app/page.tsx +0 -27
- package/templates/consumer/functions/_keep.ts +0 -13
- package/templates/consumer/functions/seedPosts.ts +0 -56
- package/templates/consumer/tests/example.test.ts +0 -12
- package/templates/creator/app/not-found.tsx +0 -44
- package/templates/directory/app/not-found.tsx +0 -44
- package/templates/local-service/app/not-found.tsx +0 -44
- package/templates/restaurant/app/not-found.tsx +0 -44
- package/templates/shop/app/not-found.tsx +0 -44
- 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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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-[
|
|
95
|
-
{/*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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="
|
|
120
|
-
<span>
|
|
121
|
-
|
|
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
|
|
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="
|
|
137
|
-
No tools match.
|
|
138
|
-
|
|
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
|
|
142
|
+
<ul>
|
|
142
143
|
{search.hits.map((l) => (
|
|
143
|
-
<
|
|
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
|
|
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-
|
|
171
|
-
(active ? "
|
|
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=
|
|
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
|
|
183
|
+
function ListingRowItem({ listing }: { listing: ListingRow }) {
|
|
182
184
|
const tags = parseTags(listing.tags);
|
|
183
185
|
return (
|
|
184
|
-
<li className="
|
|
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
|
|
187
|
-
<div className="flex flex-wrap items-
|
|
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.
|
|
197
|
-
|
|
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-
|
|
196
|
+
<p className="mt-0.5 text-[14px] leading-snug text-zinc-600">{listing.tagline}</p>
|
|
206
197
|
{tags.length > 0 ? (
|
|
207
|
-
<
|
|
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={`
|
|
221
|
-
className="mt-
|
|
205
|
+
aria-label={`Open ${listing.name}`}
|
|
206
|
+
className="mt-0.5 text-zinc-400 hover:text-brand"
|
|
222
207
|
>
|
|
223
|
-
<
|
|
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
|
|
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
|
-
<
|
|
279
|
-
|
|
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
|
|
268
|
+
<ul>
|
|
307
269
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
308
|
-
<li key={i} className="
|
|
309
|
-
<div className="h-
|
|
310
|
-
<div className="
|
|
311
|
-
<div className="h-
|
|
312
|
-
<div className="h-3 w-2/3 animate-pulse
|
|
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-
|
|
322
|
-
<div className="mt-6 grid gap-8 lg:grid-cols-[
|
|
323
|
-
<div className="hidden h-40 animate-pulse
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
20
|
-
// `guest_…` user id
|
|
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="
|
|
28
|
-
<div className=
|
|
29
|
-
<Link href="/" className="
|
|
30
|
-
|
|
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-
|
|
38
|
-
<a href="/#browse" className=
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
79
|
-
<div className=
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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=
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
</
|
|
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 {
|
|
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` → `/`.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 {
|
|
19
|
+
const { brand, intro } = siteConfig;
|
|
19
20
|
return (
|
|
20
|
-
<div className=
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
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
|
-
</
|
|
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
|
|
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)
|
|
15
|
-
// directory
|
|
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
|
-
<
|
|
20
|
-
<div className="
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
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
|
-
</
|
|
27
|
+
</div>
|
|
29
28
|
);
|
|
30
29
|
}
|