@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,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Link, usePathname } from "@pylonsync/react";
|
|
5
|
+
import { Compass, House, SquarePlus } from "lucide-react";
|
|
6
|
+
import { SITE } from "@/lib/site";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { Avatar } from "./avatar";
|
|
9
|
+
import { CreatePostDialog } from "./create-post";
|
|
10
|
+
import { SocialGate } from "./session";
|
|
11
|
+
import { useSocial } from "./use-social";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Navigation: a sidebar from the md breakpoint up, and a top bar with a tab
|
|
15
|
+
* bar underneath the page below it. Create opens the new-post dialog.
|
|
16
|
+
*/
|
|
17
|
+
export function AppChrome() {
|
|
18
|
+
return (
|
|
19
|
+
<SocialGate fallback={<ChromeFrame profileHref="/" profile={null} onCreate={() => {}} />}>
|
|
20
|
+
<LiveChrome />
|
|
21
|
+
</SocialGate>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function LiveChrome() {
|
|
26
|
+
const social = useSocial();
|
|
27
|
+
const [creating, setCreating] = useState(false);
|
|
28
|
+
const href = social.myProfile ? `/u/${social.myProfile.username}` : "/";
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<ChromeFrame profileHref={href} profile={social.myProfile} onCreate={() => setCreating(true)} />
|
|
32
|
+
<CreatePostDialog open={creating} onOpenChange={setCreating} />
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ChromeFrame({
|
|
38
|
+
profileHref,
|
|
39
|
+
profile,
|
|
40
|
+
onCreate,
|
|
41
|
+
}: {
|
|
42
|
+
profileHref: string;
|
|
43
|
+
profile: Parameters<typeof Avatar>[0]["profile"];
|
|
44
|
+
onCreate: () => void;
|
|
45
|
+
}) {
|
|
46
|
+
const path = usePathname();
|
|
47
|
+
const onProfile = profileHref !== "/" && path === profileHref;
|
|
48
|
+
const items = [
|
|
49
|
+
{ label: "Home", href: "/", active: path === "/", icon: House },
|
|
50
|
+
{ label: "Explore", href: "/explore", active: path === "/explore", icon: Compass },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{/* Sidebar, md and up. */}
|
|
56
|
+
<nav className="fixed inset-y-0 left-0 z-30 hidden w-[72px] flex-col border-r bg-background px-3 pb-5 pt-7 md:flex xl:w-[244px]">
|
|
57
|
+
<Link href="/" className="mb-8 flex h-10 items-center px-3">
|
|
58
|
+
<span className="font-display text-[28px] italic leading-none xl:inline hidden">{SITE.name}</span>
|
|
59
|
+
<span className="font-display text-[28px] italic leading-none xl:hidden">{SITE.name.charAt(0)}</span>
|
|
60
|
+
</Link>
|
|
61
|
+
<div className="flex flex-col gap-1">
|
|
62
|
+
{items.map((item) => (
|
|
63
|
+
<Link
|
|
64
|
+
key={item.href}
|
|
65
|
+
href={item.href}
|
|
66
|
+
className="flex h-12 items-center gap-4 rounded-lg px-3 transition-colors hover:bg-zinc-100"
|
|
67
|
+
>
|
|
68
|
+
<item.icon className="size-6 shrink-0" strokeWidth={item.active ? 2.5 : 1.75} />
|
|
69
|
+
<span className={cn("hidden text-[15px] xl:inline", item.active ? "font-bold" : "")}>{item.label}</span>
|
|
70
|
+
</Link>
|
|
71
|
+
))}
|
|
72
|
+
<button type="button" onClick={onCreate} className="flex h-12 items-center gap-4 rounded-lg px-3 text-left transition-colors hover:bg-zinc-100">
|
|
73
|
+
<SquarePlus className="size-6 shrink-0" strokeWidth={1.75} />
|
|
74
|
+
<span className="hidden text-[15px] xl:inline">Create</span>
|
|
75
|
+
</button>
|
|
76
|
+
<Link href={profileHref} className="flex h-12 items-center gap-4 rounded-lg px-3 transition-colors hover:bg-zinc-100">
|
|
77
|
+
<Avatar profile={profile} size={24} className={onProfile ? "outline outline-2 outline-offset-1 outline-zinc-900" : ""} />
|
|
78
|
+
<span className={cn("hidden text-[15px] xl:inline", onProfile ? "font-bold" : "")}>Profile</span>
|
|
79
|
+
</Link>
|
|
80
|
+
</div>
|
|
81
|
+
</nav>
|
|
82
|
+
|
|
83
|
+
{/* Top bar, below md. */}
|
|
84
|
+
<header className="sticky top-0 z-30 flex h-12 items-center justify-between border-b bg-background px-4 md:hidden">
|
|
85
|
+
<Link href="/" className="font-display text-[26px] italic leading-none">
|
|
86
|
+
{SITE.name}
|
|
87
|
+
</Link>
|
|
88
|
+
<button type="button" onClick={onCreate} aria-label="Create" className="p-1">
|
|
89
|
+
<SquarePlus className="size-6" strokeWidth={1.75} />
|
|
90
|
+
</button>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
{/* Tab bar, below md. */}
|
|
94
|
+
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-12 items-center justify-around border-t bg-background md:hidden">
|
|
95
|
+
{items.map((item) => (
|
|
96
|
+
<Link key={item.href} href={item.href} aria-label={item.label} className="p-2">
|
|
97
|
+
<item.icon className="size-6" strokeWidth={item.active ? 2.5 : 1.75} />
|
|
98
|
+
</Link>
|
|
99
|
+
))}
|
|
100
|
+
<button type="button" onClick={onCreate} aria-label="Create" className="p-2">
|
|
101
|
+
<SquarePlus className="size-6" strokeWidth={1.75} />
|
|
102
|
+
</button>
|
|
103
|
+
<Link href={profileHref} aria-label="Profile" className="p-2">
|
|
104
|
+
<Avatar profile={profile} size={24} />
|
|
105
|
+
</Link>
|
|
106
|
+
</nav>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import type { Profile } from "@/lib/social";
|
|
6
|
+
|
|
7
|
+
/** A round profile photo, or the first letter of the name when there is none. */
|
|
8
|
+
export function Avatar({
|
|
9
|
+
profile,
|
|
10
|
+
size = 32,
|
|
11
|
+
ring = false,
|
|
12
|
+
className,
|
|
13
|
+
}: {
|
|
14
|
+
profile: Pick<Profile, "username" | "displayName" | "avatarUrl"> | null | undefined;
|
|
15
|
+
size?: number;
|
|
16
|
+
/** A colored ring, for people who posted recently. */
|
|
17
|
+
ring?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const letter = (profile?.displayName || profile?.username || "?").trim().charAt(0).toUpperCase();
|
|
21
|
+
const inner = profile?.avatarUrl ? (
|
|
22
|
+
<img
|
|
23
|
+
src={profile.avatarUrl}
|
|
24
|
+
alt=""
|
|
25
|
+
width={size}
|
|
26
|
+
height={size}
|
|
27
|
+
loading="lazy"
|
|
28
|
+
className="size-full rounded-full object-cover"
|
|
29
|
+
/>
|
|
30
|
+
) : (
|
|
31
|
+
<span
|
|
32
|
+
className="grid size-full place-items-center rounded-full bg-zinc-200 font-semibold text-zinc-600"
|
|
33
|
+
style={{ fontSize: Math.max(10, Math.round(size * 0.4)) }}
|
|
34
|
+
>
|
|
35
|
+
{letter}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
return (
|
|
39
|
+
<span
|
|
40
|
+
className={cn(
|
|
41
|
+
"inline-block shrink-0 rounded-full",
|
|
42
|
+
ring ? "bg-brand p-[2px]" : "",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
style={{ width: size + (ring ? 4 : 0), height: size + (ring ? 4 : 0) }}
|
|
46
|
+
>
|
|
47
|
+
<span className={cn("block size-full rounded-full", ring ? "bg-background p-[2px]" : "")}>{inner}</span>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { callFn } from "@pylonsync/react";
|
|
5
|
+
import { COMMENT_MAX } from "@/lib/social";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
/** "Add a comment" with a Post button that appears once there is text. */
|
|
9
|
+
export function CommentForm({ postId, className, autoFocus }: { postId: string; className?: string; autoFocus?: boolean }) {
|
|
10
|
+
const [text, setText] = useState("");
|
|
11
|
+
const [busy, setBusy] = useState(false);
|
|
12
|
+
const [error, setError] = useState<string | null>(null);
|
|
13
|
+
|
|
14
|
+
async function submit(e: React.FormEvent) {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
const value = text.trim();
|
|
17
|
+
if (!value || busy) return;
|
|
18
|
+
setBusy(true);
|
|
19
|
+
setError(null);
|
|
20
|
+
try {
|
|
21
|
+
await callFn("addComment", { postId, text: value });
|
|
22
|
+
setText("");
|
|
23
|
+
} catch (caught) {
|
|
24
|
+
setError(caught instanceof Error ? caught.message : "Could not post that comment.");
|
|
25
|
+
} finally {
|
|
26
|
+
setBusy(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<form onSubmit={submit} className={cn("flex items-center gap-2", className)}>
|
|
32
|
+
<input
|
|
33
|
+
value={text}
|
|
34
|
+
onChange={(e) => setText(e.target.value)}
|
|
35
|
+
maxLength={COMMENT_MAX}
|
|
36
|
+
placeholder={error ?? "Add a comment…"}
|
|
37
|
+
aria-label="Add a comment"
|
|
38
|
+
autoFocus={autoFocus}
|
|
39
|
+
className={cn(
|
|
40
|
+
"min-w-0 flex-1 bg-transparent py-2 text-sm outline-none placeholder:text-zinc-500",
|
|
41
|
+
error ? "placeholder:text-red-600" : "",
|
|
42
|
+
)}
|
|
43
|
+
/>
|
|
44
|
+
{text.trim() ? (
|
|
45
|
+
<button type="submit" disabled={busy} className="text-sm font-semibold text-brand hover:text-zinc-900 disabled:opacity-50">
|
|
46
|
+
Post
|
|
47
|
+
</button>
|
|
48
|
+
) : null}
|
|
49
|
+
</form>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { callFn, uploadFile, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { ImagePlus } from "lucide-react";
|
|
6
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
7
|
+
import { CAPTION_MAX } from "@/lib/social";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Choose a photo, pick the crop, write a caption, share. The photo uploads
|
|
14
|
+
* as public, so every visitor can load it, then createPost stores the post.
|
|
15
|
+
*/
|
|
16
|
+
export function CreatePostDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
19
|
+
const [file, setFile] = useState<File | null>(null);
|
|
20
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
21
|
+
const [shape, setShape] = useState<"square" | "portrait">("square");
|
|
22
|
+
const [caption, setCaption] = useState("");
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [dragging, setDragging] = useState(false);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!file) {
|
|
29
|
+
setPreview(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const url = URL.createObjectURL(file);
|
|
33
|
+
setPreview(url);
|
|
34
|
+
return () => URL.revokeObjectURL(url);
|
|
35
|
+
}, [file]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (open) return;
|
|
39
|
+
setFile(null);
|
|
40
|
+
setCaption("");
|
|
41
|
+
setShape("square");
|
|
42
|
+
setError(null);
|
|
43
|
+
setBusy(false);
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
function choose(next: File | undefined | null) {
|
|
47
|
+
if (!next) return;
|
|
48
|
+
if (!next.type.startsWith("image/")) return setError("Choose a photo.");
|
|
49
|
+
if (next.size > MAX_BYTES) return setError("Choose a photo smaller than 10 MB.");
|
|
50
|
+
setError(null);
|
|
51
|
+
setFile(next);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function share() {
|
|
55
|
+
if (!file) return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
const photo = await uploadFile(file, { visibility: "public" });
|
|
60
|
+
const out = await callFn<{ id: string }>("createPost", { imageUrl: photo.url, caption, shape });
|
|
61
|
+
onOpenChange(false);
|
|
62
|
+
router.push(`/p/${out.id}`);
|
|
63
|
+
} catch (caught) {
|
|
64
|
+
setError(caught instanceof Error ? caught.message : "Could not share that photo.");
|
|
65
|
+
setBusy(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Dialog open={open} onOpenChange={(next) => (busy ? null : onOpenChange(next))}>
|
|
71
|
+
<DialogContent className={cn("gap-0 overflow-hidden p-0", file ? "max-w-3xl" : "max-w-md")}>
|
|
72
|
+
<div className="flex h-11 items-center justify-center border-b px-12">
|
|
73
|
+
<DialogTitle className="text-base font-semibold">Create new post</DialogTitle>
|
|
74
|
+
{file ? (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={share}
|
|
78
|
+
disabled={busy}
|
|
79
|
+
className="absolute right-12 text-sm font-semibold text-brand hover:text-zinc-900 disabled:opacity-50"
|
|
80
|
+
>
|
|
81
|
+
{busy ? "Sharing…" : "Share"}
|
|
82
|
+
</button>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{!file ? (
|
|
87
|
+
<div
|
|
88
|
+
onDragOver={(e) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
setDragging(true);
|
|
91
|
+
}}
|
|
92
|
+
onDragLeave={() => setDragging(false)}
|
|
93
|
+
onDrop={(e) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
setDragging(false);
|
|
96
|
+
choose(e.dataTransfer.files?.[0]);
|
|
97
|
+
}}
|
|
98
|
+
className={cn("flex aspect-square flex-col items-center justify-center gap-4 p-8", dragging ? "bg-zinc-50" : "")}
|
|
99
|
+
>
|
|
100
|
+
<ImagePlus className="size-16 text-zinc-800" strokeWidth={1} />
|
|
101
|
+
<p className="text-lg">Drag a photo here</p>
|
|
102
|
+
<input ref={input} type="file" accept="image/*" className="sr-only" onChange={(e) => choose(e.target.files?.[0])} />
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => input.current?.click()}
|
|
106
|
+
className="rounded-lg bg-brand px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
|
|
107
|
+
>
|
|
108
|
+
Select from computer
|
|
109
|
+
</button>
|
|
110
|
+
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="grid md:grid-cols-[1fr_300px]">
|
|
114
|
+
<div className="flex items-center justify-center bg-zinc-100">
|
|
115
|
+
{preview ? (
|
|
116
|
+
<img src={preview} alt="" className={cn("w-full object-cover", shape === "portrait" ? "aspect-[4/5]" : "aspect-square")} />
|
|
117
|
+
) : null}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex flex-col gap-3 border-l p-4">
|
|
120
|
+
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 text-sm">
|
|
121
|
+
{(["square", "portrait"] as const).map((s) => (
|
|
122
|
+
<button
|
|
123
|
+
key={s}
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => setShape(s)}
|
|
126
|
+
aria-pressed={shape === s}
|
|
127
|
+
className={cn("flex-1 rounded-md py-1 font-medium", shape === s ? "bg-white shadow-sm" : "text-zinc-500")}
|
|
128
|
+
>
|
|
129
|
+
{s === "square" ? "Square" : "Portrait"}
|
|
130
|
+
</button>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
<textarea
|
|
134
|
+
value={caption}
|
|
135
|
+
onChange={(e) => setCaption(e.target.value)}
|
|
136
|
+
maxLength={CAPTION_MAX}
|
|
137
|
+
rows={8}
|
|
138
|
+
placeholder="Write a caption…"
|
|
139
|
+
aria-label="Caption"
|
|
140
|
+
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500"
|
|
141
|
+
/>
|
|
142
|
+
<p className="text-right text-xs text-zinc-400">
|
|
143
|
+
{caption.length.toLocaleString("en-US")}/{CAPTION_MAX.toLocaleString("en-US")}
|
|
144
|
+
</p>
|
|
145
|
+
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
|
146
|
+
<button type="button" onClick={() => setFile(null)} disabled={busy} className="mt-auto text-left text-sm text-zinc-500 hover:text-zinc-900">
|
|
147
|
+
Choose another photo
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</DialogContent>
|
|
153
|
+
</Dialog>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { callFn, uploadFile, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
+
import { BIO_MAX, DISPLAY_NAME_MAX, USERNAME_MAX, normalizeUsername, usernameProblem, type Profile } from "@/lib/social";
|
|
7
|
+
import { Avatar } from "./avatar";
|
|
8
|
+
|
|
9
|
+
const field = "w-full rounded-lg border bg-transparent px-3 py-2 text-sm outline-none focus:border-zinc-400";
|
|
10
|
+
|
|
11
|
+
/** Photo, username, name, and bio. The server checks the username again. */
|
|
12
|
+
export function EditProfileDialog({ profile, open, onOpenChange }: { profile: Profile; open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
15
|
+
const [username, setUsername] = useState(profile.username);
|
|
16
|
+
const [displayName, setDisplayName] = useState(profile.displayName);
|
|
17
|
+
const [bio, setBio] = useState(profile.bio ?? "");
|
|
18
|
+
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl ?? "");
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!open) return;
|
|
24
|
+
setUsername(profile.username);
|
|
25
|
+
setDisplayName(profile.displayName);
|
|
26
|
+
setBio(profile.bio ?? "");
|
|
27
|
+
setAvatarUrl(profile.avatarUrl ?? "");
|
|
28
|
+
setError(null);
|
|
29
|
+
}, [open, profile]);
|
|
30
|
+
|
|
31
|
+
async function pickPhoto(file: File | undefined) {
|
|
32
|
+
if (!file) return;
|
|
33
|
+
if (!file.type.startsWith("image/")) return setError("Choose a photo.");
|
|
34
|
+
setBusy(true);
|
|
35
|
+
setError(null);
|
|
36
|
+
try {
|
|
37
|
+
const photo = await uploadFile(file, { visibility: "public" });
|
|
38
|
+
setAvatarUrl(photo.url);
|
|
39
|
+
} catch (caught) {
|
|
40
|
+
setError(caught instanceof Error ? caught.message : "Could not upload that photo.");
|
|
41
|
+
} finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function save(e: React.FormEvent) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
const next = normalizeUsername(username);
|
|
49
|
+
const problem = usernameProblem(next);
|
|
50
|
+
if (problem) return setError(problem);
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setError(null);
|
|
53
|
+
try {
|
|
54
|
+
await callFn("updateProfile", { username: next, displayName, bio, avatarUrl });
|
|
55
|
+
onOpenChange(false);
|
|
56
|
+
if (next !== profile.username) router.replace(`/u/${next}`);
|
|
57
|
+
} catch (caught) {
|
|
58
|
+
setError(caught instanceof Error ? caught.message : "Could not save your profile.");
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Dialog open={open} onOpenChange={(next) => (busy ? null : onOpenChange(next))}>
|
|
66
|
+
<DialogContent className="max-w-md">
|
|
67
|
+
<DialogTitle className="text-base font-semibold">Edit profile</DialogTitle>
|
|
68
|
+
<form onSubmit={save} className="grid gap-4">
|
|
69
|
+
<div className="flex items-center gap-4 rounded-xl bg-zinc-100 p-3">
|
|
70
|
+
<Avatar profile={{ ...profile, avatarUrl }} size={56} />
|
|
71
|
+
<div className="min-w-0 flex-1">
|
|
72
|
+
<p className="truncate text-sm font-semibold">{profile.username}</p>
|
|
73
|
+
<p className="truncate text-sm text-zinc-500">{profile.displayName}</p>
|
|
74
|
+
</div>
|
|
75
|
+
<input ref={input} type="file" accept="image/*" className="sr-only" onChange={(e) => void pickPhoto(e.target.files?.[0])} />
|
|
76
|
+
<button type="button" disabled={busy} onClick={() => input.current?.click()} className="rounded-lg bg-brand px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50">
|
|
77
|
+
Change photo
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<label className="grid gap-1.5 text-sm">
|
|
81
|
+
<span className="font-semibold">Username</span>
|
|
82
|
+
<input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={USERNAME_MAX + 1} className={field} autoCapitalize="none" autoCorrect="off" />
|
|
83
|
+
</label>
|
|
84
|
+
<label className="grid gap-1.5 text-sm">
|
|
85
|
+
<span className="font-semibold">Name</span>
|
|
86
|
+
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={DISPLAY_NAME_MAX} className={field} />
|
|
87
|
+
</label>
|
|
88
|
+
<label className="grid gap-1.5 text-sm">
|
|
89
|
+
<span className="font-semibold">Bio</span>
|
|
90
|
+
<textarea value={bio} onChange={(e) => setBio(e.target.value)} maxLength={BIO_MAX} rows={3} className={`${field} resize-none`} />
|
|
91
|
+
<span className="text-right text-xs text-zinc-400">
|
|
92
|
+
{bio.length}/{BIO_MAX}
|
|
93
|
+
</span>
|
|
94
|
+
</label>
|
|
95
|
+
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
|
96
|
+
<button type="submit" disabled={busy} className="h-10 rounded-lg bg-brand text-sm font-semibold text-white disabled:opacity-50">
|
|
97
|
+
{busy ? "Saving…" : "Save"}
|
|
98
|
+
</button>
|
|
99
|
+
</form>
|
|
100
|
+
</DialogContent>
|
|
101
|
+
</Dialog>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { GridSkeleton, PostGrid } from "./post-grid";
|
|
5
|
+
import { SocialGate } from "./session";
|
|
6
|
+
import { useSocial } from "./use-social";
|
|
7
|
+
|
|
8
|
+
export function ExploreScreen() {
|
|
9
|
+
return (
|
|
10
|
+
<div className="mx-auto max-w-[935px] px-0 py-2 sm:px-5 sm:py-8">
|
|
11
|
+
<SocialGate fallback={<GridSkeleton count={12} />}>
|
|
12
|
+
<LiveExplore />
|
|
13
|
+
</SocialGate>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function LiveExplore() {
|
|
19
|
+
const social = useSocial();
|
|
20
|
+
if (social.loading) return <GridSkeleton count={12} />;
|
|
21
|
+
if (social.posts.length === 0) return <p className="py-16 text-center text-sm text-zinc-500">No posts yet.</p>;
|
|
22
|
+
return <PostGrid social={social} posts={social.posts} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
|
+
import { homeFeed, plural, recentPosters, suggestions } from "@/lib/social";
|
|
6
|
+
import { SITE } from "@/lib/site";
|
|
7
|
+
import { Avatar } from "./avatar";
|
|
8
|
+
import { PostCard } from "./post-card";
|
|
9
|
+
import { SocialGate } from "./session";
|
|
10
|
+
import { actions, useSocial, type Social } from "./use-social";
|
|
11
|
+
|
|
12
|
+
export function HomeScreen() {
|
|
13
|
+
return (
|
|
14
|
+
<SocialGate fallback={<HomeFrame />}>
|
|
15
|
+
<LiveHome />
|
|
16
|
+
</SocialGate>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function LiveHome() {
|
|
21
|
+
const social = useSocial();
|
|
22
|
+
const feed = homeFeed(social.posts, social.following, social.me);
|
|
23
|
+
const recent = recentPosters(social.posts).slice(0, 8);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<HomeFrame
|
|
27
|
+
rail={<Rail social={social} />}
|
|
28
|
+
recent={
|
|
29
|
+
recent.length ? (
|
|
30
|
+
<ul className="flex gap-3 overflow-x-auto px-3 pb-4 pt-2 sm:px-0">
|
|
31
|
+
{recent.map((userId) => {
|
|
32
|
+
const profile = social.profileByUser.get(userId);
|
|
33
|
+
if (!profile) return null;
|
|
34
|
+
return (
|
|
35
|
+
<li key={userId} className="w-[74px] shrink-0">
|
|
36
|
+
<Link href={`/u/${profile.username}`} className="flex flex-col items-center gap-1">
|
|
37
|
+
<Avatar profile={profile} size={62} ring />
|
|
38
|
+
<span className="w-full truncate text-center text-xs">{profile.username}</span>
|
|
39
|
+
</Link>
|
|
40
|
+
</li>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</ul>
|
|
44
|
+
) : null
|
|
45
|
+
}
|
|
46
|
+
>
|
|
47
|
+
{social.loading ? (
|
|
48
|
+
<FeedSkeleton />
|
|
49
|
+
) : feed.posts.length === 0 ? (
|
|
50
|
+
<p className="px-3 py-16 text-center text-sm text-zinc-500">No posts yet. Share the first photo with Create.</p>
|
|
51
|
+
) : (
|
|
52
|
+
<>
|
|
53
|
+
{feed.everyone ? (
|
|
54
|
+
<p className="px-3 pb-2 text-sm text-zinc-500 sm:px-0">Posts from everyone. Follow people to see only theirs.</p>
|
|
55
|
+
) : null}
|
|
56
|
+
<div className="flex flex-col gap-4">
|
|
57
|
+
{feed.posts.map((post) => (
|
|
58
|
+
<PostCard key={post.id} social={social} post={post} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</HomeFrame>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function HomeFrame({ children, rail, recent }: { children?: React.ReactNode; rail?: React.ReactNode; recent?: React.ReactNode }) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="mx-auto flex max-w-[935px] justify-center gap-16 pt-2 sm:pt-6">
|
|
70
|
+
<div className="w-full max-w-[470px]">
|
|
71
|
+
{recent}
|
|
72
|
+
{children ?? <FeedSkeleton />}
|
|
73
|
+
</div>
|
|
74
|
+
<aside className="hidden w-[320px] shrink-0 pt-6 lg:block">{rail}</aside>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Rail({ social }: { social: Social }) {
|
|
80
|
+
const people = suggestions(social.profiles, social.follows, social.following, social.me, 5);
|
|
81
|
+
const me = social.myProfile;
|
|
82
|
+
return (
|
|
83
|
+
<div className="sticky top-8">
|
|
84
|
+
{me ? (
|
|
85
|
+
<div className="flex items-center gap-3">
|
|
86
|
+
<Link href={`/u/${me.username}`}>
|
|
87
|
+
<Avatar profile={me} size={44} />
|
|
88
|
+
</Link>
|
|
89
|
+
<div className="min-w-0 flex-1 text-sm">
|
|
90
|
+
<Link href={`/u/${me.username}`} className="block truncate font-semibold">
|
|
91
|
+
{me.username}
|
|
92
|
+
</Link>
|
|
93
|
+
<span className="block truncate text-zinc-500">{me.displayName}</span>
|
|
94
|
+
</div>
|
|
95
|
+
<Link href={`/u/${me.username}`} className="text-xs font-semibold text-brand hover:text-zinc-900">
|
|
96
|
+
Edit profile
|
|
97
|
+
</Link>
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
|
|
101
|
+
{people.length ? (
|
|
102
|
+
<>
|
|
103
|
+
<div className="mb-2 mt-6 flex items-center justify-between text-sm">
|
|
104
|
+
<span className="font-semibold text-zinc-500">Suggested for you</span>
|
|
105
|
+
<Link href="/explore" className="text-xs font-semibold hover:text-zinc-500">
|
|
106
|
+
See all
|
|
107
|
+
</Link>
|
|
108
|
+
</div>
|
|
109
|
+
<ul className="flex flex-col gap-3">
|
|
110
|
+
{people.map((p) => {
|
|
111
|
+
const followers = social.followerCount.get(p.userId) ?? 0;
|
|
112
|
+
return (
|
|
113
|
+
<li key={p.id} className="flex items-center gap-3">
|
|
114
|
+
<Link href={`/u/${p.username}`}>
|
|
115
|
+
<Avatar profile={p} size={32} />
|
|
116
|
+
</Link>
|
|
117
|
+
<div className="min-w-0 flex-1 text-sm">
|
|
118
|
+
<Link href={`/u/${p.username}`} className="block truncate font-semibold">
|
|
119
|
+
{p.username}
|
|
120
|
+
</Link>
|
|
121
|
+
<span className="block truncate text-xs text-zinc-500">{plural(followers, "follower")}</span>
|
|
122
|
+
</div>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => actions.toggleFollow(social, p.userId)}
|
|
126
|
+
className="text-xs font-semibold text-brand hover:text-zinc-900"
|
|
127
|
+
>
|
|
128
|
+
Follow
|
|
129
|
+
</button>
|
|
130
|
+
</li>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</ul>
|
|
134
|
+
</>
|
|
135
|
+
) : null}
|
|
136
|
+
|
|
137
|
+
<p className="mt-8 text-xs text-zinc-400">
|
|
138
|
+
{SITE.name} · Built with{" "}
|
|
139
|
+
<a href="https://pylonsync.com" className="hover:underline">
|
|
140
|
+
Pylon
|
|
141
|
+
</a>
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function FeedSkeleton() {
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex flex-col gap-6" aria-hidden>
|
|
150
|
+
{[0, 1].map((i) => (
|
|
151
|
+
<div key={i}>
|
|
152
|
+
<div className="flex items-center gap-2.5 px-3 py-3 sm:px-0">
|
|
153
|
+
<span className="size-8 rounded-full bg-zinc-100" />
|
|
154
|
+
<span className="h-3 w-28 rounded bg-zinc-100" />
|
|
155
|
+
</div>
|
|
156
|
+
<div className="aspect-square w-full bg-zinc-100 sm:rounded-[4px]" />
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|