@pylonsync/create-pylon 0.11.1 → 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 +1 -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/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/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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# __APP_NAME__
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
3
|
+
A photo-sharing app on [Pylon](https://pylonsync.com): a home feed, an
|
|
4
|
+
explore grid, post pages with comments, and profiles with followers and
|
|
5
|
+
saved posts. Likes, comments, and follows update live in every open tab.
|
|
5
6
|
|
|
6
7
|
## Develop
|
|
7
8
|
|
|
@@ -9,38 +10,53 @@ optimistic posts, and likes over one synced backend.
|
|
|
9
10
|
__RUN_DEV__
|
|
10
11
|
```
|
|
11
12
|
|
|
12
|
-
Open http://localhost:4321
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Open http://localhost:4321. The first visit loads twelve demo people with
|
|
14
|
+
their posts, likes, comments, and follows, and gives you a guest profile.
|
|
15
|
+
Post a photo with Create, like with a double tap, and open a second tab to
|
|
16
|
+
watch counts change.
|
|
15
17
|
|
|
16
18
|
## Layout
|
|
17
19
|
|
|
18
20
|
```
|
|
19
|
-
app.ts
|
|
20
|
-
app/
|
|
21
|
-
app/
|
|
22
|
-
app/
|
|
23
|
-
app/
|
|
21
|
+
app.ts data model, policies, fonts
|
|
22
|
+
app/(app)/layout.tsx navigation around every page
|
|
23
|
+
app/(app)/page.tsx / home feed
|
|
24
|
+
app/(app)/explore/page.tsx /explore every post in a grid
|
|
25
|
+
app/(app)/p/[id]/page.tsx /p/:id one post and its comments
|
|
26
|
+
app/(app)/u/[username]/page.tsx /u/:username a profile
|
|
27
|
+
components/social/ the client islands for those pages
|
|
28
|
+
lib/social.ts usernames, counts, feed rules (pure, tested)
|
|
29
|
+
lib/seed.ts the demo people and posts
|
|
30
|
+
lib/site.ts the app's name
|
|
31
|
+
functions/ ensureProfile, updateProfile, createPost,
|
|
32
|
+
addComment, seedFeed
|
|
33
|
+
public/images/ demo avatars and photos
|
|
24
34
|
```
|
|
25
35
|
|
|
26
36
|
## How it works
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
- **Sessions.** `components/social/session.tsx` wraps each island in
|
|
39
|
+
`<EnsureGuest>`, so every visitor gets a session without a sign-up form,
|
|
40
|
+
then calls `ensureProfile` to give them a username.
|
|
41
|
+
- **Reads are public, writes are yours.** Every entity is readable by
|
|
42
|
+
anyone except `Save`, which only its owner can see. A like, follow, or
|
|
43
|
+
save must carry the caller's own id; the policies in `app.ts` refuse any
|
|
44
|
+
other. Posts, comments, and profile edits go through functions, which
|
|
45
|
+
check image URLs, caption length, and username rules on the server.
|
|
46
|
+
- **Photos.** Create uploads the file with
|
|
47
|
+
`uploadFile(file, { visibility: "public" })` from `@pylonsync/react`, so
|
|
48
|
+
every visitor can load it, then calls `createPost` with its URL.
|
|
49
|
+
- **Counts.** A like count is how many `Like` rows point at a post. The
|
|
50
|
+
queries are live, so a count changes the moment someone taps.
|
|
35
51
|
|
|
36
52
|
## Grow it
|
|
37
53
|
|
|
38
|
-
- **
|
|
39
|
-
|
|
40
|
-
- **
|
|
41
|
-
|
|
42
|
-
- **
|
|
43
|
-
|
|
54
|
+
- **Real accounts:** email and password are built in. Add a sign-in page and
|
|
55
|
+
move a guest's profile to the account on sign-in.
|
|
56
|
+
- **Scale:** `components/social/use-social.ts` reads every row, which is fine
|
|
57
|
+
for a demo. For a large network, query per screen with `where` and `limit`.
|
|
58
|
+
- **Launch:** delete `functions/seedFeed.ts`, `lib/seed.ts`, and
|
|
59
|
+
`public/images/`, and remove the `seedFeed` call in `session.tsx`.
|
|
44
60
|
|
|
45
61
|
## Deploy
|
|
46
62
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { ExploreScreen } from "@/components/social/explore-screen";
|
|
4
|
+
import { SITE } from "@/lib/site";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Explore · ${SITE.name}`,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// `app/(app)/explore/page.tsx` → `/explore`. Every post, newest first.
|
|
11
|
+
export default function ExplorePage() {
|
|
12
|
+
return <ExploreScreen />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AppChrome } from "@/components/social/app-chrome";
|
|
3
|
+
|
|
4
|
+
interface LayoutProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// `(app)` is a route group: the parens segment is not part of any URL. This
|
|
9
|
+
// layout adds the navigation around every page of the app.
|
|
10
|
+
export default function AppLayout({ children }: LayoutProps) {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<AppChrome />
|
|
14
|
+
<main className="pb-14 md:pb-0 md:pl-[72px] xl:pl-[244px]">{children}</main>
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { PostScreen } from "@/components/social/post-screen";
|
|
4
|
+
import { SITE } from "@/lib/site";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Post · ${SITE.name}`,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// `app/(app)/p/[id]/page.tsx` → `/p/:id`. One post with all its comments.
|
|
11
|
+
export default function PostPage({ params }: PageProps<{ id: string }>) {
|
|
12
|
+
return <PostScreen postId={params.id} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { HomeScreen } from "@/components/social/home-screen";
|
|
4
|
+
import { SITE } from "@/lib/site";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: SITE.name,
|
|
8
|
+
description: SITE.description,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/(app)/page.tsx` → `/`. The feed is a client island: it needs the
|
|
12
|
+
// visitor's session to know who they follow and what they liked.
|
|
13
|
+
export default function HomePage() {
|
|
14
|
+
return <HomeScreen />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { ProfileScreen } from "@/components/social/profile-screen";
|
|
4
|
+
import { SITE } from "@/lib/site";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Profile · ${SITE.name}`,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// `app/(app)/u/[username]/page.tsx` → `/u/:username`. A person's posts,
|
|
11
|
+
// followers, and following. Your own profile also shows your saved posts.
|
|
12
|
+
export default function ProfilePage({ params }: PageProps<{ username: string }>) {
|
|
13
|
+
return <ProfileScreen username={params.username} />;
|
|
14
|
+
}
|
|
@@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button";
|
|
|
11
11
|
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
12
12
|
const [tries, setTries] = React.useState(0);
|
|
13
13
|
return (
|
|
14
|
-
<div className="space-y-6">
|
|
14
|
+
<div className="mx-auto max-w-md space-y-6 px-6 py-24">
|
|
15
15
|
<section>
|
|
16
16
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
17
17
|
Something went wrong
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
markup elsewhere. */
|
|
7
7
|
@source "../app/**/*.{tsx,ts,jsx,js}";
|
|
8
8
|
@source "../components/**/*.{tsx,ts,jsx,js}";
|
|
9
|
+
@source "../node_modules/@pylonsync/client/**/*.{tsx,ts,jsx,js}";
|
|
9
10
|
|
|
10
11
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
11
12
|
|
|
@@ -15,6 +16,9 @@
|
|
|
15
16
|
on <html>. */
|
|
16
17
|
:root {
|
|
17
18
|
--radius: 0.625rem;
|
|
19
|
+
/* The app's own colors: links and the follow button, and a liked heart. */
|
|
20
|
+
--brand: #2563eb;
|
|
21
|
+
--like: #e11d48;
|
|
18
22
|
--background: oklch(1 0 0);
|
|
19
23
|
--foreground: oklch(0.141 0.005 285.823);
|
|
20
24
|
--card: oklch(1 0 0);
|
|
@@ -83,6 +87,8 @@
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
@theme inline {
|
|
90
|
+
--color-brand: var(--brand);
|
|
91
|
+
--color-like: var(--like);
|
|
86
92
|
--radius-sm: calc(var(--radius) - 4px);
|
|
87
93
|
--radius-md: calc(var(--radius) - 2px);
|
|
88
94
|
--radius-lg: var(--radius);
|
|
@@ -132,8 +138,27 @@
|
|
|
132
138
|
body {
|
|
133
139
|
background-color: var(--color-background);
|
|
134
140
|
color: var(--color-foreground);
|
|
141
|
+
font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, -apple-system, sans-serif);
|
|
142
|
+
-webkit-font-smoothing: antialiased;
|
|
135
143
|
}
|
|
136
144
|
button {
|
|
137
145
|
cursor: pointer;
|
|
138
146
|
}
|
|
139
147
|
}
|
|
148
|
+
|
|
149
|
+
/* The wordmark. Instrument Serif is declared in app.ts. */
|
|
150
|
+
.font-display {
|
|
151
|
+
font-family: var(--font-display, "Iowan Old Style", Georgia, serif);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* The heart that pops over a photo on a double tap. */
|
|
155
|
+
@keyframes heart-pop {
|
|
156
|
+
0% { transform: scale(0.2); opacity: 0; }
|
|
157
|
+
15% { transform: scale(1.15); opacity: 1; }
|
|
158
|
+
30% { transform: scale(0.95); }
|
|
159
|
+
45%, 80% { transform: scale(1); opacity: 1; }
|
|
160
|
+
100% { transform: scale(1); opacity: 0; }
|
|
161
|
+
}
|
|
162
|
+
.heart-pop {
|
|
163
|
+
animation: heart-pop 900ms ease-out forwards;
|
|
164
|
+
}
|
|
@@ -4,8 +4,8 @@ interface LayoutProps {
|
|
|
4
4
|
children: React.ReactNode;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
// The
|
|
8
|
-
//
|
|
7
|
+
// The document shell. The app chrome (sidebar and mobile bars) lives in
|
|
8
|
+
// `(app)/layout.tsx`, so a page outside that group renders bare.
|
|
9
9
|
export default function RootLayout({ children }: LayoutProps) {
|
|
10
10
|
return (
|
|
11
11
|
<html lang="en">
|
|
@@ -14,14 +14,7 @@ export default function RootLayout({ children }: LayoutProps) {
|
|
|
14
14
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
15
15
|
<title>__APP_NAME__</title>
|
|
16
16
|
</head>
|
|
17
|
-
<body className="min-h-screen bg-background text-foreground antialiased">
|
|
18
|
-
<header className="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
|
|
19
|
-
<div className="mx-auto max-w-lg px-4 py-3 text-sm font-semibold tracking-tight">
|
|
20
|
-
__APP_NAME__
|
|
21
|
-
</div>
|
|
22
|
-
</header>
|
|
23
|
-
<main className="mx-auto max-w-lg px-4 py-8">{children}</main>
|
|
24
|
-
</body>
|
|
17
|
+
<body className="min-h-screen bg-background text-foreground antialiased">{children}</body>
|
|
25
18
|
</html>
|
|
26
19
|
);
|
|
27
20
|
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import type { LlmsTxt } from "@pylonsync/react";
|
|
2
|
+
import { SITE } from "@/lib/site";
|
|
2
3
|
|
|
3
4
|
// app/llms.ts → served at /llms.txt, in llmstxt.org format.
|
|
4
5
|
//
|
|
5
6
|
// This is the file an AI agent reads FIRST to decide what this site is and
|
|
6
|
-
// whether it can help with the task in front of it. Keep the shape, rewrite
|
|
7
|
-
// copy: name the jobs this app is right for and the exact call to make.
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
// The default export may be async, so it can enumerate pages from the database.
|
|
11
|
-
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
7
|
+
// whether it can help with the task in front of it. Keep the shape, rewrite
|
|
8
|
+
// the copy: name the jobs this app is right for and the exact call to make.
|
|
9
|
+
const BASE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
12
10
|
|
|
13
11
|
export default function llms(): LlmsTxt {
|
|
14
12
|
return {
|
|
15
|
-
title:
|
|
16
|
-
summary: "A
|
|
13
|
+
title: SITE.name,
|
|
14
|
+
summary: "A photo-sharing app: people post photos, follow each other, like, comment, and save posts.",
|
|
17
15
|
details: [
|
|
18
|
-
"
|
|
16
|
+
"Reading posts, profiles, and comments needs no account. Liking, commenting, following, and posting need a session; a visitor gets a guest session automatically.",
|
|
17
|
+
"Post pages are at /p/<id> and profiles at /u/<username>.",
|
|
19
18
|
"Every page here is also readable as markdown: add `.md` to any path, or send `Accept: text/markdown`.",
|
|
20
19
|
],
|
|
21
20
|
sections: [
|
|
22
21
|
{
|
|
23
22
|
title: "Pages",
|
|
24
23
|
links: [
|
|
25
|
-
{ title: "
|
|
24
|
+
{ title: "Feed", url: `${BASE}/` },
|
|
25
|
+
{ title: "Explore", url: `${BASE}/explore` },
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
28
|
],
|
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Link,
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
4
3
|
|
|
5
|
-
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL
|
|
6
|
-
//
|
|
7
|
-
// the buttons below use the client router. Not-found boundaries receive the
|
|
8
|
-
// standard page props (and, matching Next, no `reset`).
|
|
4
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL, and when a
|
|
5
|
+
// page calls `response.notFound()`.
|
|
9
6
|
export default function NotFound(_props: NotFoundProps) {
|
|
10
|
-
const router = useRouter();
|
|
11
7
|
return (
|
|
12
|
-
<div className="
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
</section>
|
|
19
|
-
<div className="flex items-center gap-3">
|
|
20
|
-
<Button onClick={() => router.back()} variant="outline">
|
|
21
|
-
← Go back
|
|
22
|
-
</Button>
|
|
23
|
-
<Button asChild>
|
|
24
|
-
<Link href="/">Home</Link>
|
|
25
|
-
</Button>
|
|
26
|
-
</div>
|
|
8
|
+
<div className="mx-auto max-w-md px-6 py-24 text-center">
|
|
9
|
+
<h1 className="text-xl font-semibold">This page is not available.</h1>
|
|
10
|
+
<p className="mt-2 text-sm text-zinc-500">The link may be broken, or the page may have been removed.</p>
|
|
11
|
+
<Link href="/" className="mt-6 inline-block text-sm font-semibold text-brand">
|
|
12
|
+
Back to the feed
|
|
13
|
+
</Link>
|
|
27
14
|
</div>
|
|
28
15
|
);
|
|
29
16
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { Robots } from "@pylonsync/react";
|
|
2
2
|
|
|
3
3
|
// app/robots.ts → served at /robots.txt. The default export may also be async.
|
|
4
|
-
const
|
|
4
|
+
const BASE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
5
|
|
|
6
6
|
export default function robots(): Robots {
|
|
7
7
|
return {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
sitemap: `${SITE}/sitemap.xml`,
|
|
8
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/api/"] },
|
|
9
|
+
sitemap: `${BASE}/sitemap.xml`,
|
|
11
10
|
};
|
|
12
11
|
}
|
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
import type { Sitemap } from "@pylonsync/react";
|
|
2
2
|
|
|
3
|
-
// app/sitemap.ts → served at /sitemap.xml. The default export can be async,
|
|
4
|
-
// it can
|
|
5
|
-
// domain in production.
|
|
6
|
-
const
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. The default export can be async,
|
|
4
|
+
// so it can list post and profile pages from the database once you have
|
|
5
|
+
// real ones. Point SITE_URL at your domain in production.
|
|
6
|
+
const BASE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
7
7
|
|
|
8
8
|
export default async function sitemap(): Promise<Sitemap> {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{ url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
|
|
13
|
-
{ url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
|
|
14
|
-
{ url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
|
|
9
|
+
return [
|
|
10
|
+
{ url: `${BASE}/`, changeFrequency: "hourly", priority: 1 },
|
|
11
|
+
{ url: `${BASE}/explore`, changeFrequency: "hourly", priority: 0.8 },
|
|
15
12
|
];
|
|
16
|
-
|
|
17
|
-
// The export is async, so you can enumerate dynamic pages from a DB read:
|
|
18
|
-
//
|
|
19
|
-
// const posts = await fetchPublishedPosts();
|
|
20
|
-
// const postRoutes: Sitemap = posts.map((p) => ({
|
|
21
|
-
// url: `${SITE}/blog/${p.slug}`,
|
|
22
|
-
// lastModified: p.updatedAt,
|
|
23
|
-
// }));
|
|
24
|
-
// return [...staticRoutes, ...postRoutes];
|
|
25
|
-
|
|
26
|
-
return staticRoutes;
|
|
27
13
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
entity,
|
|
3
3
|
field,
|
|
4
|
+
font,
|
|
4
5
|
policy,
|
|
5
6
|
auth,
|
|
6
7
|
buildManifest,
|
|
@@ -8,71 +9,167 @@ import {
|
|
|
8
9
|
discoverFunctions,
|
|
9
10
|
} from "@pylonsync/sdk";
|
|
10
11
|
|
|
11
|
-
// A
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// A photo-sharing app: profiles, photo posts, likes, comments, follows, and
|
|
13
|
+
// saves. Everything people post is public to read. Every write carries the
|
|
14
|
+
// writer's id, and the policies below (or the functions, for posts,
|
|
15
|
+
// comments, and profiles) check it against the session, so a client cannot
|
|
16
|
+
// post, like, or follow as someone else. Guest sessions
|
|
17
|
+
// (components/social/session.tsx) let a visitor post without a sign-up form.
|
|
18
|
+
//
|
|
19
|
+
// The ids are plain fields, not `field.owner()`, because the demo data in
|
|
20
|
+
// lib/seed.ts is written for twelve made-up people, and an owner field only
|
|
21
|
+
// accepts the caller's own id.
|
|
22
|
+
//
|
|
23
|
+
// • Profile — the public face of a user: username, name, bio, avatar.
|
|
24
|
+
// • Post — one photo and a caption.
|
|
25
|
+
// • Like, Save, Follow — join rows. A count is how many rows point at a thing.
|
|
26
|
+
// • Comment — text on a post.
|
|
27
|
+
|
|
28
|
+
const Profile = entity(
|
|
29
|
+
"Profile",
|
|
30
|
+
{
|
|
31
|
+
userId: field.string().unique(),
|
|
32
|
+
username: field.string().unique(),
|
|
33
|
+
displayName: field.string(),
|
|
34
|
+
bio: field.string().optional(),
|
|
35
|
+
avatarUrl: field.string().optional(),
|
|
36
|
+
createdAt: field.datetime().defaultNow(),
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
16
40
|
const Post = entity(
|
|
17
41
|
"Post",
|
|
18
42
|
{
|
|
19
|
-
authorId: field.string()
|
|
20
|
-
|
|
43
|
+
authorId: field.string(),
|
|
44
|
+
imageUrl: field.string(),
|
|
45
|
+
caption: field.string().optional(),
|
|
46
|
+
// "square" (1:1) or "portrait" (4:5): how the feed crops the photo.
|
|
47
|
+
shape: field.string().default("square"),
|
|
21
48
|
createdAt: field.datetime().defaultNow(),
|
|
22
49
|
},
|
|
23
|
-
{
|
|
50
|
+
{
|
|
51
|
+
indexes: [
|
|
52
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
53
|
+
{ name: "by_author", fields: ["authorId"], unique: false },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
24
56
|
);
|
|
25
57
|
|
|
26
|
-
// A like is a join row (one per user per post). To toggle a like the client
|
|
27
|
-
// inserts or deletes this row; the like count for a post is just how many
|
|
28
|
-
// Like rows point at it. `userId: field.owner()` keeps a like attributable to
|
|
29
|
-
// exactly one user.
|
|
30
58
|
const Like = entity(
|
|
31
59
|
"Like",
|
|
32
60
|
{
|
|
33
|
-
userId: field.string()
|
|
61
|
+
userId: field.string(),
|
|
34
62
|
postId: field.string(),
|
|
35
63
|
createdAt: field.datetime().defaultNow(),
|
|
36
64
|
},
|
|
37
65
|
{
|
|
38
66
|
indexes: [
|
|
39
67
|
{ name: "by_post", fields: ["postId"], unique: false },
|
|
40
|
-
// One like per
|
|
41
|
-
// no-op at the storage layer.
|
|
68
|
+
// One like per person per post: a second tap is a no-op at storage.
|
|
42
69
|
{ name: "by_user_post", fields: ["userId", "postId"], unique: true },
|
|
43
70
|
],
|
|
44
71
|
},
|
|
45
72
|
);
|
|
46
73
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
const Comment = entity(
|
|
75
|
+
"Comment",
|
|
76
|
+
{
|
|
77
|
+
postId: field.string(),
|
|
78
|
+
authorId: field.string(),
|
|
79
|
+
text: field.string(),
|
|
80
|
+
createdAt: field.datetime().defaultNow(),
|
|
81
|
+
},
|
|
82
|
+
{ indexes: [{ name: "by_post", fields: ["postId"], unique: false }] },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const Follow = entity(
|
|
86
|
+
"Follow",
|
|
87
|
+
{
|
|
88
|
+
followerId: field.string(),
|
|
89
|
+
followingId: field.string(),
|
|
90
|
+
createdAt: field.datetime().defaultNow(),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
indexes: [
|
|
94
|
+
{ name: "by_following", fields: ["followingId"], unique: false },
|
|
95
|
+
{ name: "by_pair", fields: ["followerId", "followingId"], unique: true },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Saves are private: only the person who saved a post can see that they did.
|
|
101
|
+
const Save = entity(
|
|
102
|
+
"Save",
|
|
103
|
+
{
|
|
104
|
+
userId: field.string(),
|
|
105
|
+
postId: field.string(),
|
|
106
|
+
createdAt: field.datetime().defaultNow(),
|
|
107
|
+
},
|
|
108
|
+
{ indexes: [{ name: "by_user_post", fields: ["userId", "postId"], unique: true }] },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// An entity with no policy is denied to clients, so these allow-lists are
|
|
112
|
+
// what make the app work. An insert must carry the caller's own id; a delete
|
|
113
|
+
// is allowed only on the caller's own row.
|
|
114
|
+
//
|
|
115
|
+
// Profile, Post, and Comment writes go through functions (ensureProfile,
|
|
116
|
+
// updateProfile, createPost, addComment) so usernames, captions, and image
|
|
117
|
+
// URLs are checked on the server. Likes, follows, and saves are plain
|
|
118
|
+
// inserts and deletes from the client.
|
|
119
|
+
const profilePolicy = policy({
|
|
120
|
+
name: "profile_public",
|
|
121
|
+
entity: "Profile",
|
|
122
|
+
allowRead: "true",
|
|
123
|
+
allowInsert: "false",
|
|
124
|
+
allowUpdate: "false",
|
|
125
|
+
allowDelete: "false",
|
|
126
|
+
});
|
|
127
|
+
|
|
53
128
|
const postPolicy = policy({
|
|
54
|
-
name: "
|
|
129
|
+
name: "post_public",
|
|
55
130
|
entity: "Post",
|
|
56
131
|
allowRead: "true",
|
|
57
|
-
allowInsert: "
|
|
58
|
-
allowUpdate: "
|
|
132
|
+
allowInsert: "false",
|
|
133
|
+
allowUpdate: "false",
|
|
59
134
|
allowDelete: "auth.userId == data.authorId",
|
|
60
135
|
});
|
|
61
136
|
|
|
62
137
|
const likePolicy = policy({
|
|
63
|
-
name: "
|
|
138
|
+
name: "like_public",
|
|
64
139
|
entity: "Like",
|
|
65
140
|
allowRead: "true",
|
|
66
|
-
allowInsert: "auth.userId != null",
|
|
141
|
+
allowInsert: "auth.userId != null && data.userId == auth.userId",
|
|
142
|
+
allowUpdate: "false",
|
|
143
|
+
allowDelete: "auth.userId == data.userId",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const commentPolicy = policy({
|
|
147
|
+
name: "comment_public",
|
|
148
|
+
entity: "Comment",
|
|
149
|
+
allowRead: "true",
|
|
150
|
+
allowInsert: "false",
|
|
151
|
+
allowUpdate: "false",
|
|
152
|
+
allowDelete: "auth.userId == data.authorId",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const followPolicy = policy({
|
|
156
|
+
name: "follow_public",
|
|
157
|
+
entity: "Follow",
|
|
158
|
+
allowRead: "true",
|
|
159
|
+
allowInsert: "auth.userId != null && data.followerId == auth.userId && data.followingId != auth.userId",
|
|
160
|
+
allowUpdate: "false",
|
|
161
|
+
allowDelete: "auth.userId == data.followerId",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const savePolicy = policy({
|
|
165
|
+
name: "save_private",
|
|
166
|
+
entity: "Save",
|
|
167
|
+
allowRead: "auth.userId == data.userId",
|
|
168
|
+
allowInsert: "auth.userId != null && data.userId == auth.userId",
|
|
67
169
|
allowUpdate: "false",
|
|
68
170
|
allowDelete: "auth.userId == data.userId",
|
|
69
171
|
});
|
|
70
172
|
|
|
71
|
-
// `pylon dev` serves the SSR feed and the API from one port. Guest sessions
|
|
72
|
-
// (via `<EnsureGuest>` on the page) let every visitor post + like with no
|
|
73
|
-
// login. Natural next steps: a `Profile` entity (displayName/avatar keyed by
|
|
74
|
-
// userId) to show names instead of ids, and a `Follow` join entity
|
|
75
|
-
// (followerId/followedId) to scope the feed to people you follow.
|
|
76
173
|
// Every non-internal function in functions/ becomes a manifest entry —
|
|
77
174
|
// this is what makes them show up in /api/manifest, the OpenAPI spec,
|
|
78
175
|
// and `pylon codegen`.
|
|
@@ -81,11 +178,33 @@ const fns = await discoverFunctions();
|
|
|
81
178
|
const manifest = buildManifest({
|
|
82
179
|
name: "__APP_NAME__",
|
|
83
180
|
version: "0.1.0",
|
|
84
|
-
entities: [Post, Like],
|
|
181
|
+
entities: [Profile, Post, Like, Comment, Follow, Save],
|
|
85
182
|
queries: fns.queries,
|
|
86
183
|
actions: fns.actions,
|
|
87
|
-
policies: [postPolicy, likePolicy],
|
|
184
|
+
policies: [profilePolicy, postPolicy, likePolicy, commentPolicy, followPolicy, savePolicy],
|
|
88
185
|
auth: auth(),
|
|
186
|
+
// Self-hosted: the build fetches the woff2 files and serves them from this
|
|
187
|
+
// app, so there is no third-party font request.
|
|
188
|
+
fonts: [
|
|
189
|
+
font({
|
|
190
|
+
family: "Inter",
|
|
191
|
+
variable: "--font-sans",
|
|
192
|
+
weights: ["400", "500", "600", "700"],
|
|
193
|
+
subsets: ["latin"],
|
|
194
|
+
display: "swap",
|
|
195
|
+
preload: true,
|
|
196
|
+
}),
|
|
197
|
+
// The wordmark.
|
|
198
|
+
font({
|
|
199
|
+
family: "Instrument Serif",
|
|
200
|
+
variable: "--font-display",
|
|
201
|
+
weights: ["400"],
|
|
202
|
+
styles: ["normal", "italic"],
|
|
203
|
+
subsets: ["latin"],
|
|
204
|
+
display: "swap",
|
|
205
|
+
preload: true,
|
|
206
|
+
}),
|
|
207
|
+
],
|
|
89
208
|
routes: await discoverAppRoutes(),
|
|
90
209
|
});
|
|
91
210
|
|