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