@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { plural, timeAgo, type Post } from "@/lib/social";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { Avatar } from "./avatar";
|
|
8
|
+
import { CommentForm } from "./comment-form";
|
|
9
|
+
import { LikesLine, PostActions, PostMenu, PostPhoto } from "./post-parts";
|
|
10
|
+
import { RichText } from "./rich-text";
|
|
11
|
+
import type { Social } from "./use-social";
|
|
12
|
+
|
|
13
|
+
/** One post in the home feed. */
|
|
14
|
+
export function PostCard({ social, post }: { social: Social; post: Post }) {
|
|
15
|
+
const author = social.profileByUser.get(post.authorId);
|
|
16
|
+
const comments = social.commentsByPost.get(post.id) ?? [];
|
|
17
|
+
const shown = comments.slice(-2);
|
|
18
|
+
const username = author?.username ?? "unknown";
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<article className="border-b pb-4 sm:pb-5">
|
|
22
|
+
<header className="flex items-center gap-2.5 px-3 py-3 sm:px-0">
|
|
23
|
+
<Link href={`/u/${username}`}>
|
|
24
|
+
<Avatar profile={author} size={32} />
|
|
25
|
+
</Link>
|
|
26
|
+
<div className="flex min-w-0 items-baseline gap-1.5 text-sm">
|
|
27
|
+
<Link href={`/u/${username}`} className="truncate font-semibold hover:text-zinc-500">
|
|
28
|
+
{username}
|
|
29
|
+
</Link>
|
|
30
|
+
<span className="text-zinc-500">·</span>
|
|
31
|
+
<Link href={`/p/${post.id}`} className="shrink-0 text-zinc-500 hover:underline">
|
|
32
|
+
<time dateTime={post.createdAt}>{timeAgo(post.createdAt)}</time>
|
|
33
|
+
</Link>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="ml-auto">
|
|
36
|
+
<PostMenu social={social} post={post} />
|
|
37
|
+
</div>
|
|
38
|
+
</header>
|
|
39
|
+
|
|
40
|
+
<PostPhoto
|
|
41
|
+
social={social}
|
|
42
|
+
post={post}
|
|
43
|
+
className={cn("w-full sm:rounded-[4px] sm:border", post.shape === "portrait" ? "aspect-[4/5]" : "aspect-square")}
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<div className="px-3 pt-1 sm:px-0">
|
|
47
|
+
<PostActions social={social} post={post} />
|
|
48
|
+
<LikesLine social={social} post={post} />
|
|
49
|
+
{post.caption ? (
|
|
50
|
+
<p className="mt-1.5 text-sm leading-snug">
|
|
51
|
+
<Link href={`/u/${username}`} className="mr-1.5 font-semibold">
|
|
52
|
+
{username}
|
|
53
|
+
</Link>
|
|
54
|
+
<RichText text={post.caption} />
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
{comments.length > shown.length ? (
|
|
58
|
+
<Link href={`/p/${post.id}`} className="mt-1.5 block text-sm text-zinc-500">
|
|
59
|
+
View all {plural(comments.length, "comment")}
|
|
60
|
+
</Link>
|
|
61
|
+
) : null}
|
|
62
|
+
{shown.map((c) => {
|
|
63
|
+
const who = social.profileByUser.get(c.authorId)?.username ?? "unknown";
|
|
64
|
+
return (
|
|
65
|
+
<p key={c.id} className="mt-1 text-sm leading-snug">
|
|
66
|
+
<Link href={`/u/${who}`} className="mr-1.5 font-semibold">
|
|
67
|
+
{who}
|
|
68
|
+
</Link>
|
|
69
|
+
<RichText text={c.text} />
|
|
70
|
+
</p>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
<CommentForm postId={post.id} className="mt-1" />
|
|
74
|
+
</div>
|
|
75
|
+
</article>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { Heart, MessageCircle } from "lucide-react";
|
|
6
|
+
import { formatCount, type Post } from "@/lib/social";
|
|
7
|
+
import type { Social } from "./use-social";
|
|
8
|
+
|
|
9
|
+
/** Square tiles, three across. Hover shows the like and comment counts. */
|
|
10
|
+
export function PostGrid({ social, posts }: { social: Social; posts: Post[] }) {
|
|
11
|
+
return (
|
|
12
|
+
<ul className="grid grid-cols-3 gap-1">
|
|
13
|
+
{posts.map((post) => {
|
|
14
|
+
const likes = social.likeCount.get(post.id) ?? 0;
|
|
15
|
+
const comments = social.commentsByPost.get(post.id)?.length ?? 0;
|
|
16
|
+
return (
|
|
17
|
+
<li key={post.id}>
|
|
18
|
+
<Link href={`/p/${post.id}`} className="group relative block aspect-square overflow-hidden bg-zinc-100">
|
|
19
|
+
<img src={post.imageUrl} alt={post.caption ?? ""} loading="lazy" className="size-full object-cover" />
|
|
20
|
+
<span className="absolute inset-0 hidden items-center justify-center gap-6 bg-black/35 text-base font-bold text-white group-hover:flex">
|
|
21
|
+
<span className="flex items-center gap-1.5">
|
|
22
|
+
<Heart className="size-5 fill-white" />
|
|
23
|
+
{formatCount(likes)}
|
|
24
|
+
</span>
|
|
25
|
+
<span className="flex items-center gap-1.5">
|
|
26
|
+
<MessageCircle className="size-5 -scale-x-100 fill-white" />
|
|
27
|
+
{formatCount(comments)}
|
|
28
|
+
</span>
|
|
29
|
+
</span>
|
|
30
|
+
</Link>
|
|
31
|
+
</li>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</ul>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function GridSkeleton({ count = 9 }: { count?: number }) {
|
|
39
|
+
return (
|
|
40
|
+
<ul className="grid grid-cols-3 gap-1" aria-hidden>
|
|
41
|
+
{Array.from({ length: count }, (_, i) => (
|
|
42
|
+
<li key={i} className="aspect-square bg-zinc-100" />
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { db, Link, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { Bookmark, Heart, MessageCircle, MoreHorizontal, Send } from "lucide-react";
|
|
6
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
7
|
+
import { plural, type Post } from "@/lib/social";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { actions, type Social } from "./use-social";
|
|
10
|
+
|
|
11
|
+
/** Heart, comment, share, and save, in one row. */
|
|
12
|
+
export function PostActions({ social, post, onComment }: { social: Social; post: Post; onComment?: () => void }) {
|
|
13
|
+
const liked = social.myLikes.has(post.id);
|
|
14
|
+
const saved = social.mySaves.has(post.id);
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
|
|
17
|
+
async function share() {
|
|
18
|
+
const url = `${window.location.origin}/p/${post.id}`;
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(url);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
23
|
+
} catch {
|
|
24
|
+
window.prompt("Copy this link", url);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="-mx-2 flex items-center">
|
|
30
|
+
<IconButton label={liked ? "Unlike" : "Like"} onClick={() => actions.toggleLike(social, post.id)}>
|
|
31
|
+
<Heart className={cn("size-6 transition-transform active:scale-90", liked ? "fill-like text-like" : "")} strokeWidth={liked ? 2 : 1.75} />
|
|
32
|
+
</IconButton>
|
|
33
|
+
{onComment ? (
|
|
34
|
+
<IconButton label="Comment" onClick={onComment}>
|
|
35
|
+
<MessageCircle className="size-6 -scale-x-100" strokeWidth={1.75} />
|
|
36
|
+
</IconButton>
|
|
37
|
+
) : (
|
|
38
|
+
<Link href={`/p/${post.id}`} aria-label="Comment" className="p-2 hover:opacity-60">
|
|
39
|
+
<MessageCircle className="size-6 -scale-x-100" strokeWidth={1.75} />
|
|
40
|
+
</Link>
|
|
41
|
+
)}
|
|
42
|
+
<IconButton label="Copy link" onClick={share}>
|
|
43
|
+
<Send className="size-6" strokeWidth={1.75} />
|
|
44
|
+
</IconButton>
|
|
45
|
+
{copied ? <span className="text-xs text-zinc-500">Link copied</span> : null}
|
|
46
|
+
<IconButton label={saved ? "Remove from saved" : "Save"} onClick={() => actions.toggleSave(social, post.id)} className="ml-auto">
|
|
47
|
+
<Bookmark className={cn("size-6", saved ? "fill-current" : "")} strokeWidth={1.75} />
|
|
48
|
+
</IconButton>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function LikesLine({ social, post }: { social: Social; post: Post }) {
|
|
54
|
+
const count = social.likeCount.get(post.id) ?? 0;
|
|
55
|
+
if (count === 0) {
|
|
56
|
+
return <p className="text-sm text-zinc-500">Be the first to like this</p>;
|
|
57
|
+
}
|
|
58
|
+
return <p className="text-sm font-semibold">{plural(count, "like")}</p>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function IconButton({ label, onClick, className, children }: { label: string; onClick: () => void; className?: string; children: React.ReactNode }) {
|
|
62
|
+
return (
|
|
63
|
+
<button type="button" aria-label={label} title={label} onClick={onClick} className={cn("p-2 hover:opacity-60", className)}>
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The "…" menu: open the post, copy its link, and delete it when it is yours. */
|
|
70
|
+
export function PostMenu({ social, post }: { social: Social; post: Post }) {
|
|
71
|
+
const router = useRouter();
|
|
72
|
+
const [open, setOpen] = useState(false);
|
|
73
|
+
const mine = post.authorId === social.me;
|
|
74
|
+
const row = "w-full border-t py-3.5 text-center text-sm first:border-t-0 hover:bg-zinc-50";
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<button type="button" aria-label="More options" onClick={() => setOpen(true)} className="p-1 hover:opacity-60">
|
|
78
|
+
<MoreHorizontal className="size-5" />
|
|
79
|
+
</button>
|
|
80
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
81
|
+
<DialogContent className="max-w-sm gap-0 overflow-hidden p-0 [&>button:last-child]:hidden">
|
|
82
|
+
<DialogTitle className="sr-only">Post options</DialogTitle>
|
|
83
|
+
{mine ? (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className={cn(row, "font-semibold text-red-600")}
|
|
87
|
+
onClick={() => {
|
|
88
|
+
setOpen(false);
|
|
89
|
+
void db.delete("Post", post.id);
|
|
90
|
+
if (window.location.pathname.startsWith("/p/")) router.push("/");
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
Delete
|
|
94
|
+
</button>
|
|
95
|
+
) : null}
|
|
96
|
+
<button type="button" className={row} onClick={() => { setOpen(false); router.push(`/p/${post.id}`); }}>
|
|
97
|
+
Go to post
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className={row}
|
|
102
|
+
onClick={() => {
|
|
103
|
+
setOpen(false);
|
|
104
|
+
void navigator.clipboard?.writeText(`${window.location.origin}/p/${post.id}`);
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
Copy link
|
|
108
|
+
</button>
|
|
109
|
+
<button type="button" className={row} onClick={() => setOpen(false)}>
|
|
110
|
+
Cancel
|
|
111
|
+
</button>
|
|
112
|
+
</DialogContent>
|
|
113
|
+
</Dialog>
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The photo. A double tap likes the post and pops a heart over it, the way
|
|
120
|
+
* people expect a photo app to work.
|
|
121
|
+
*/
|
|
122
|
+
export function PostPhoto({ social, post, className, fit = "cover" }: { social: Social; post: Post; className?: string; fit?: "cover" | "contain" }) {
|
|
123
|
+
const [pops, setPops] = useState(0);
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={cn("relative select-none overflow-hidden bg-zinc-100", className)}
|
|
127
|
+
onDoubleClick={() => {
|
|
128
|
+
actions.like(social, post.id);
|
|
129
|
+
setPops((n) => n + 1);
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<img
|
|
133
|
+
src={post.imageUrl}
|
|
134
|
+
alt={post.caption ?? ""}
|
|
135
|
+
loading="lazy"
|
|
136
|
+
draggable={false}
|
|
137
|
+
className={cn("size-full", fit === "cover" ? "object-cover" : "object-contain")}
|
|
138
|
+
/>
|
|
139
|
+
{pops > 0 ? (
|
|
140
|
+
<span key={pops} className="pointer-events-none absolute inset-0 grid place-items-center">
|
|
141
|
+
<Heart className="heart-pop size-24 fill-white text-white drop-shadow-lg" strokeWidth={1} />
|
|
142
|
+
</span>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef } from "react";
|
|
4
|
+
import { db, Link } from "@pylonsync/react";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import { timeAgo, type Comment } from "@/lib/social";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { Avatar } from "./avatar";
|
|
9
|
+
import { CommentForm } from "./comment-form";
|
|
10
|
+
import { LikesLine, PostActions, PostMenu, PostPhoto } from "./post-parts";
|
|
11
|
+
import { PostGrid } from "./post-grid";
|
|
12
|
+
import { RichText } from "./rich-text";
|
|
13
|
+
import { SocialGate } from "./session";
|
|
14
|
+
import { actions, useSocial, type Social } from "./use-social";
|
|
15
|
+
|
|
16
|
+
export function PostScreen({ postId }: { postId: string }) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="mx-auto max-w-[935px] py-0 sm:px-5 sm:py-8">
|
|
19
|
+
<SocialGate fallback={<div className="aspect-[4/3] w-full bg-zinc-100 sm:rounded-md" />}>
|
|
20
|
+
<LivePost postId={postId} />
|
|
21
|
+
</SocialGate>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function LivePost({ postId }: { postId: string }) {
|
|
27
|
+
const social = useSocial();
|
|
28
|
+
const commentInput = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
const post = social.posts.find((p) => p.id === postId);
|
|
30
|
+
if (!post) {
|
|
31
|
+
return social.loading ? (
|
|
32
|
+
<div className="aspect-[4/3] w-full bg-zinc-100 sm:rounded-md" />
|
|
33
|
+
) : (
|
|
34
|
+
<div className="py-24 text-center">
|
|
35
|
+
<p className="text-lg font-semibold">This post is not available.</p>
|
|
36
|
+
<p className="mt-2 text-sm text-zinc-500">It may have been deleted.</p>
|
|
37
|
+
<Link href="/" className="mt-4 inline-block text-sm font-semibold text-brand">
|
|
38
|
+
Back to the feed
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const author = social.profileByUser.get(post.authorId);
|
|
45
|
+
const username = author?.username ?? "unknown";
|
|
46
|
+
const comments = social.commentsByPost.get(post.id) ?? [];
|
|
47
|
+
const more = (social.postsByAuthor.get(post.authorId) ?? []).filter((p) => p.id !== post.id).slice(0, 6);
|
|
48
|
+
const mine = post.authorId === social.me;
|
|
49
|
+
const followingAuthor = social.following.has(post.authorId);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<article className="flex flex-col overflow-hidden border-y bg-background sm:rounded-md sm:border md:h-[min(86vh,760px)] md:flex-row">
|
|
54
|
+
<PostPhoto
|
|
55
|
+
social={social}
|
|
56
|
+
post={post}
|
|
57
|
+
fit="contain"
|
|
58
|
+
className={cn("bg-black md:h-full md:flex-1", post.shape === "portrait" ? "aspect-[4/5] md:aspect-auto" : "aspect-square md:aspect-auto")}
|
|
59
|
+
/>
|
|
60
|
+
<div className="flex min-h-0 flex-col md:w-[400px] md:shrink-0 md:border-l">
|
|
61
|
+
<header className="flex items-center gap-3 border-b px-4 py-3">
|
|
62
|
+
<Link href={`/u/${username}`}>
|
|
63
|
+
<Avatar profile={author} size={32} />
|
|
64
|
+
</Link>
|
|
65
|
+
<Link href={`/u/${username}`} className="text-sm font-semibold hover:text-zinc-500">
|
|
66
|
+
{username}
|
|
67
|
+
</Link>
|
|
68
|
+
{!mine && social.me ? (
|
|
69
|
+
<>
|
|
70
|
+
<span className="text-zinc-400">·</span>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={() => actions.toggleFollow(social, post.authorId)}
|
|
74
|
+
className={cn("text-sm font-semibold", followingAuthor ? "text-zinc-900" : "text-brand hover:text-zinc-900")}
|
|
75
|
+
>
|
|
76
|
+
{followingAuthor ? "Following" : "Follow"}
|
|
77
|
+
</button>
|
|
78
|
+
</>
|
|
79
|
+
) : null}
|
|
80
|
+
<div className="ml-auto">
|
|
81
|
+
<PostMenu social={social} post={post} />
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
84
|
+
|
|
85
|
+
<ul className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
86
|
+
{post.caption ? (
|
|
87
|
+
<CommentRow social={social} authorId={post.authorId} text={post.caption} createdAt={post.createdAt} />
|
|
88
|
+
) : null}
|
|
89
|
+
{comments.map((c) => (
|
|
90
|
+
<CommentRow key={c.id} social={social} authorId={c.authorId} text={c.text} createdAt={c.createdAt} comment={c} />
|
|
91
|
+
))}
|
|
92
|
+
{!post.caption && comments.length === 0 ? (
|
|
93
|
+
<li className="m-auto py-8 text-center">
|
|
94
|
+
<p className="text-lg font-semibold">No comments yet.</p>
|
|
95
|
+
<p className="mt-1 text-sm text-zinc-500">Start the conversation.</p>
|
|
96
|
+
</li>
|
|
97
|
+
) : null}
|
|
98
|
+
</ul>
|
|
99
|
+
|
|
100
|
+
<div className="border-t px-4 pt-1">
|
|
101
|
+
<PostActions social={social} post={post} onComment={() => commentInput.current?.querySelector("input")?.focus()} />
|
|
102
|
+
<LikesLine social={social} post={post} />
|
|
103
|
+
<time dateTime={post.createdAt} className="mt-1 block pb-3 text-xs text-zinc-500">
|
|
104
|
+
{new Date(post.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
|
105
|
+
</time>
|
|
106
|
+
</div>
|
|
107
|
+
<div ref={commentInput} className="border-t px-4">
|
|
108
|
+
<CommentForm postId={post.id} />
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</article>
|
|
112
|
+
|
|
113
|
+
{more.length ? (
|
|
114
|
+
<section className="mt-12 border-t pt-8">
|
|
115
|
+
<p className="mb-4 px-3 text-sm font-semibold text-zinc-500 sm:px-0">
|
|
116
|
+
More posts from{" "}
|
|
117
|
+
<Link href={`/u/${username}`} className="text-zinc-900 hover:underline">
|
|
118
|
+
{username}
|
|
119
|
+
</Link>
|
|
120
|
+
</p>
|
|
121
|
+
<PostGrid social={social} posts={more} />
|
|
122
|
+
</section>
|
|
123
|
+
) : null}
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function CommentRow({
|
|
129
|
+
social,
|
|
130
|
+
authorId,
|
|
131
|
+
text,
|
|
132
|
+
createdAt,
|
|
133
|
+
comment,
|
|
134
|
+
}: {
|
|
135
|
+
social: Social;
|
|
136
|
+
authorId: string;
|
|
137
|
+
text: string;
|
|
138
|
+
createdAt: string;
|
|
139
|
+
comment?: Comment;
|
|
140
|
+
}) {
|
|
141
|
+
const who = social.profileByUser.get(authorId);
|
|
142
|
+
const username = who?.username ?? "unknown";
|
|
143
|
+
const mine = comment && comment.authorId === social.me;
|
|
144
|
+
return (
|
|
145
|
+
<li className="group flex gap-3">
|
|
146
|
+
<Link href={`/u/${username}`} className="shrink-0">
|
|
147
|
+
<Avatar profile={who} size={32} />
|
|
148
|
+
</Link>
|
|
149
|
+
<div className="min-w-0 flex-1 text-sm leading-snug">
|
|
150
|
+
<p className="break-words">
|
|
151
|
+
<Link href={`/u/${username}`} className="mr-1.5 font-semibold">
|
|
152
|
+
{username}
|
|
153
|
+
</Link>
|
|
154
|
+
<RichText text={text} />
|
|
155
|
+
</p>
|
|
156
|
+
<p className="mt-1 flex items-center gap-3 text-xs text-zinc-500">
|
|
157
|
+
<time dateTime={createdAt}>{timeAgo(createdAt)}</time>
|
|
158
|
+
{mine ? (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => void db.delete("Comment", comment.id)}
|
|
162
|
+
className="hidden items-center gap-1 hover:text-zinc-900 group-hover:inline-flex"
|
|
163
|
+
aria-label="Delete comment"
|
|
164
|
+
>
|
|
165
|
+
<X className="size-3" /> Delete
|
|
166
|
+
</button>
|
|
167
|
+
) : null}
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
</li>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { Bookmark, Grid3x3 } from "lucide-react";
|
|
6
|
+
import { formatCount } from "@/lib/social";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { Avatar } from "./avatar";
|
|
9
|
+
import { EditProfileDialog } from "./edit-profile";
|
|
10
|
+
import { GridSkeleton, PostGrid } from "./post-grid";
|
|
11
|
+
import { SocialGate } from "./session";
|
|
12
|
+
import { actions, useSocial } from "./use-social";
|
|
13
|
+
|
|
14
|
+
export function ProfileScreen({ username }: { username: string }) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="mx-auto max-w-[935px] pb-8 sm:px-5 sm:pt-8">
|
|
17
|
+
<SocialGate fallback={<GridSkeleton />}>
|
|
18
|
+
<LiveProfile username={username.toLowerCase()} />
|
|
19
|
+
</SocialGate>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function LiveProfile({ username }: { username: string }) {
|
|
25
|
+
const social = useSocial();
|
|
26
|
+
const [tab, setTab] = useState<"posts" | "saved">("posts");
|
|
27
|
+
const [editing, setEditing] = useState(false);
|
|
28
|
+
const profile = social.profileByUsername.get(username);
|
|
29
|
+
|
|
30
|
+
if (!profile) {
|
|
31
|
+
return social.loading || social.profiles.length === 0 ? (
|
|
32
|
+
<GridSkeleton />
|
|
33
|
+
) : (
|
|
34
|
+
<div className="py-24 text-center">
|
|
35
|
+
<p className="text-lg font-semibold">This profile is not available.</p>
|
|
36
|
+
<p className="mt-2 text-sm text-zinc-500">The username may have changed.</p>
|
|
37
|
+
<Link href="/" className="mt-4 inline-block text-sm font-semibold text-brand">
|
|
38
|
+
Back to the feed
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mine = profile.userId === social.me;
|
|
45
|
+
const posts = social.postsByAuthor.get(profile.userId) ?? [];
|
|
46
|
+
const saved = mine ? social.posts.filter((p) => social.mySaves.has(p.id)) : [];
|
|
47
|
+
const following = social.following.has(profile.userId);
|
|
48
|
+
const followsYou = social.follows.some((f) => f.followerId === profile.userId && f.followingId === social.me);
|
|
49
|
+
const stats = [
|
|
50
|
+
{ label: posts.length === 1 ? "post" : "posts", value: posts.length },
|
|
51
|
+
{ label: (social.followerCount.get(profile.userId) ?? 0) === 1 ? "follower" : "followers", value: social.followerCount.get(profile.userId) ?? 0 },
|
|
52
|
+
{ label: "following", value: social.followingCount.get(profile.userId) ?? 0 },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const button = mine ? (
|
|
56
|
+
<button type="button" onClick={() => setEditing(true)} className="rounded-lg bg-zinc-100 px-4 py-1.5 text-sm font-semibold hover:bg-zinc-200">
|
|
57
|
+
Edit profile
|
|
58
|
+
</button>
|
|
59
|
+
) : (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => actions.toggleFollow(social, profile.userId)}
|
|
63
|
+
className={cn(
|
|
64
|
+
"rounded-lg px-5 py-1.5 text-sm font-semibold",
|
|
65
|
+
following ? "bg-zinc-100 hover:bg-zinc-200" : "bg-brand text-white hover:opacity-90",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{following ? "Following" : followsYou ? "Follow back" : "Follow"}
|
|
69
|
+
</button>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<header className="flex gap-6 px-4 pt-4 sm:gap-20 sm:px-12 sm:pt-0">
|
|
75
|
+
<Avatar profile={profile} size={150} className="hidden sm:inline-block" />
|
|
76
|
+
<Avatar profile={profile} size={80} className="sm:hidden" />
|
|
77
|
+
<div className="min-w-0 flex-1">
|
|
78
|
+
<div className="flex flex-wrap items-center gap-3 sm:gap-5">
|
|
79
|
+
<h1 className="truncate text-xl">{profile.username}</h1>
|
|
80
|
+
{button}
|
|
81
|
+
</div>
|
|
82
|
+
<ul className="mt-5 hidden gap-10 text-base sm:flex">
|
|
83
|
+
{stats.map((s) => (
|
|
84
|
+
<li key={s.label}>
|
|
85
|
+
<span className="font-semibold">{formatCount(s.value)}</span> {s.label}
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
<p className="mt-4 text-sm font-semibold">{profile.displayName}</p>
|
|
90
|
+
{profile.bio ? <p className="mt-0.5 whitespace-pre-line text-sm">{profile.bio}</p> : null}
|
|
91
|
+
</div>
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
<ul className="mt-6 flex justify-around border-y py-3 text-center text-sm sm:hidden">
|
|
95
|
+
{stats.map((s) => (
|
|
96
|
+
<li key={s.label}>
|
|
97
|
+
<span className="block font-semibold">{formatCount(s.value)}</span>
|
|
98
|
+
<span className="text-zinc-500">{s.label}</span>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
|
|
103
|
+
<nav className="mt-2 flex justify-center gap-14 border-t sm:mt-11">
|
|
104
|
+
<TabButton on={tab === "posts"} onClick={() => setTab("posts")} icon={<Grid3x3 className="size-3.5" />}>
|
|
105
|
+
Posts
|
|
106
|
+
</TabButton>
|
|
107
|
+
{mine ? (
|
|
108
|
+
<TabButton on={tab === "saved"} onClick={() => setTab("saved")} icon={<Bookmark className="size-3.5" />}>
|
|
109
|
+
Saved
|
|
110
|
+
</TabButton>
|
|
111
|
+
) : null}
|
|
112
|
+
</nav>
|
|
113
|
+
|
|
114
|
+
{tab === "posts" ? (
|
|
115
|
+
posts.length ? (
|
|
116
|
+
<PostGrid social={social} posts={posts} />
|
|
117
|
+
) : (
|
|
118
|
+
<p className="py-16 text-center text-sm text-zinc-500">{mine ? "Share your first photo with Create." : "No posts yet."}</p>
|
|
119
|
+
)
|
|
120
|
+
) : saved.length ? (
|
|
121
|
+
<PostGrid social={social} posts={saved} />
|
|
122
|
+
) : (
|
|
123
|
+
<p className="py-16 text-center text-sm text-zinc-500">Only you can see what you save.</p>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{mine ? <EditProfileDialog profile={profile} open={editing} onOpenChange={setEditing} /> : null}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function TabButton({ on, onClick, icon, children }: { on: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={onClick}
|
|
136
|
+
aria-pressed={on}
|
|
137
|
+
className={cn(
|
|
138
|
+
"-mt-px flex items-center gap-1.5 border-t py-4 text-xs font-semibold tracking-wide",
|
|
139
|
+
on ? "border-zinc-900 text-zinc-900" : "border-transparent text-zinc-500 hover:text-zinc-700",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{icon}
|
|
143
|
+
{children}
|
|
144
|
+
</button>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { splitMentions } from "@/lib/social";
|
|
6
|
+
|
|
7
|
+
/** Text with each @username linked to its profile. */
|
|
8
|
+
export function RichText({ text }: { text: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{splitMentions(text).map((part, i) =>
|
|
12
|
+
part.kind === "text" ? (
|
|
13
|
+
<React.Fragment key={i}>{part.text}</React.Fragment>
|
|
14
|
+
) : (
|
|
15
|
+
<Link key={i} href={`/u/${part.username}`} className="text-brand hover:underline">
|
|
16
|
+
@{part.username}
|
|
17
|
+
</Link>
|
|
18
|
+
),
|
|
19
|
+
)}
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
import { callFn } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest } from "@pylonsync/client";
|
|
6
|
+
|
|
7
|
+
// Every island on a page mounts its own <SocialGate>, so the first-visit work
|
|
8
|
+
// runs once per page load, not once per island: load the demo data, then make
|
|
9
|
+
// sure the visitor has a profile.
|
|
10
|
+
let bootstrap: Promise<void> | null = null;
|
|
11
|
+
|
|
12
|
+
function bootstrapOnce(): Promise<void> {
|
|
13
|
+
bootstrap ??= (async () => {
|
|
14
|
+
await callFn("seedFeed", {});
|
|
15
|
+
await callFn("ensureProfile", {});
|
|
16
|
+
})().catch((error) => {
|
|
17
|
+
// Let the next island retry instead of keeping a failed promise.
|
|
18
|
+
bootstrap = null;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
return bootstrap;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A guest session for anyone who opens the app, so they can like, comment,
|
|
26
|
+
* follow, and post without a sign-up form. Children render as soon as the
|
|
27
|
+
* session exists; live queries fill in as the demo data and profile arrive.
|
|
28
|
+
*/
|
|
29
|
+
export function SocialGate({ children, fallback = null }: { children: React.ReactNode; fallback?: React.ReactNode }) {
|
|
30
|
+
return (
|
|
31
|
+
<EnsureGuest fallback={fallback}>
|
|
32
|
+
<Bootstrap />
|
|
33
|
+
{children}
|
|
34
|
+
</EnsureGuest>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Bootstrap() {
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
void bootstrapOnce().catch(() => {});
|
|
41
|
+
}, []);
|
|
42
|
+
return null;
|
|
43
|
+
}
|