@pylonsync/create-pylon 0.3.267 → 0.3.269
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 +18 -10
- package/package.json +1 -1
- package/templates/b2b/AGENTS.md +61 -0
- package/templates/b2b/README.md +62 -0
- package/templates/b2b/app/auth-form.tsx +142 -0
- package/templates/b2b/app/dashboard/dashboard-client.tsx +192 -0
- package/templates/b2b/app/dashboard/page.tsx +63 -0
- package/templates/b2b/app/error.tsx +43 -0
- package/templates/b2b/app/globals.css +139 -0
- package/templates/b2b/app/layout.tsx +71 -0
- package/templates/b2b/app/login/page.tsx +47 -0
- package/templates/b2b/app/not-found.tsx +29 -0
- package/templates/b2b/app/page.tsx +114 -0
- package/templates/b2b/app/robots.ts +12 -0
- package/templates/b2b/app/signup/page.tsx +44 -0
- package/templates/b2b/app/sitemap.ts +27 -0
- package/templates/b2b/app.ts +179 -0
- package/templates/b2b/components/ui/button.tsx +56 -0
- package/templates/b2b/components/ui/card.tsx +90 -0
- package/templates/b2b/components.json +20 -0
- package/templates/b2b/functions/_keep.ts +13 -0
- package/templates/b2b/gitignore +10 -0
- package/templates/b2b/lib/utils.ts +10 -0
- package/templates/b2b/package.json +33 -0
- package/templates/b2b/tsconfig.json +18 -0
- package/templates/barebones/AGENTS.md +61 -0
- package/templates/barebones/README.md +45 -0
- package/templates/barebones/app/error.tsx +43 -0
- package/templates/barebones/app/globals.css +139 -0
- package/templates/barebones/app/items-client.tsx +96 -0
- package/templates/barebones/app/layout.tsx +27 -0
- package/templates/barebones/app/not-found.tsx +29 -0
- package/templates/barebones/app/page.tsx +28 -0
- package/templates/barebones/app/robots.ts +12 -0
- package/templates/barebones/app/sitemap.ts +27 -0
- package/templates/barebones/app.ts +55 -0
- package/templates/barebones/components/ui/button.tsx +56 -0
- package/templates/barebones/components/ui/card.tsx +90 -0
- package/templates/barebones/components.json +20 -0
- package/templates/barebones/functions/_keep.ts +13 -0
- package/templates/barebones/gitignore +10 -0
- package/templates/barebones/lib/utils.ts +10 -0
- package/templates/barebones/package.json +33 -0
- package/templates/barebones/tsconfig.json +18 -0
- package/templates/chat/AGENTS.md +61 -0
- package/templates/chat/README.md +51 -0
- package/templates/chat/app/chat-client.tsx +113 -0
- package/templates/chat/app/error.tsx +43 -0
- package/templates/chat/app/globals.css +139 -0
- package/templates/chat/app/layout.tsx +25 -0
- package/templates/chat/app/not-found.tsx +29 -0
- package/templates/chat/app/page.tsx +26 -0
- package/templates/chat/app/robots.ts +12 -0
- package/templates/chat/app/sitemap.ts +27 -0
- package/templates/chat/app.ts +59 -0
- package/templates/chat/components/ui/button.tsx +56 -0
- package/templates/chat/components/ui/card.tsx +90 -0
- package/templates/chat/components.json +20 -0
- package/templates/chat/functions/_keep.ts +13 -0
- package/templates/chat/gitignore +10 -0
- package/templates/chat/lib/utils.ts +10 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/consumer/AGENTS.md +61 -0
- package/templates/consumer/README.md +52 -0
- package/templates/consumer/app/error.tsx +43 -0
- package/templates/consumer/app/feed-client.tsx +154 -0
- package/templates/consumer/app/globals.css +139 -0
- package/templates/consumer/app/layout.tsx +27 -0
- package/templates/consumer/app/not-found.tsx +29 -0
- package/templates/consumer/app/page.tsx +27 -0
- package/templates/consumer/app/robots.ts +12 -0
- package/templates/consumer/app/sitemap.ts +27 -0
- package/templates/consumer/app.ts +89 -0
- package/templates/consumer/components/ui/button.tsx +56 -0
- package/templates/consumer/components/ui/card.tsx +90 -0
- package/templates/consumer/components.json +20 -0
- package/templates/consumer/functions/_keep.ts +13 -0
- package/templates/consumer/gitignore +10 -0
- package/templates/consumer/lib/utils.ts +10 -0
- package/templates/consumer/package.json +33 -0
- package/templates/consumer/tsconfig.json +18 -0
- package/templates/ssr/README.md +43 -28
- package/templates/ssr/app/dashboard/dashboard-client.tsx +154 -78
- package/templates/ssr/app/dashboard/page.tsx +16 -60
- package/templates/ssr/app/layout.tsx +46 -39
- package/templates/ssr/app/login/page.tsx +1 -1
- package/templates/ssr/app/page.tsx +182 -84
- package/templates/ssr/app/signup/page.tsx +1 -1
- package/templates/ssr/app.ts +134 -46
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest, useAuth } from "@pylonsync/client";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
|
|
8
|
+
export interface Message {
|
|
9
|
+
id: string;
|
|
10
|
+
authorId: string;
|
|
11
|
+
text: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// `<EnsureGuest>` mints a guest session so anyone can chat with no login.
|
|
16
|
+
// Inside it, `useAuth()` exposes the guest's `userId` so we can right-align
|
|
17
|
+
// your own messages.
|
|
18
|
+
export function ChatRoom() {
|
|
19
|
+
return (
|
|
20
|
+
<EnsureGuest fallback={<div className="flex-1" />}>
|
|
21
|
+
<Room />
|
|
22
|
+
</EnsureGuest>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Room() {
|
|
27
|
+
const { userId } = useAuth();
|
|
28
|
+
const [text, setText] = useState("");
|
|
29
|
+
const { data: messages } = db.useQuery<Message>("Message");
|
|
30
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
// Oldest → newest, like a chat transcript.
|
|
33
|
+
const ordered = [...messages].sort((a, b) =>
|
|
34
|
+
a.createdAt.localeCompare(b.createdAt),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// db.useQuery is live, so this fires whenever a message arrives (here or in
|
|
38
|
+
// another tab) — keep the newest message in view.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
41
|
+
}, [ordered.length]);
|
|
42
|
+
|
|
43
|
+
async function send(e: React.FormEvent) {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
const value = text.trim();
|
|
46
|
+
if (!value) return;
|
|
47
|
+
setText("");
|
|
48
|
+
// No authorId sent — field.owner() stamps it from the session.
|
|
49
|
+
await db.insert("Message", { text: value });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
54
|
+
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto py-2">
|
|
55
|
+
{ordered.length === 0 ? (
|
|
56
|
+
<p className="text-sm text-muted-foreground">
|
|
57
|
+
No messages yet — say hi. Open a second tab to watch it arrive live.
|
|
58
|
+
</p>
|
|
59
|
+
) : (
|
|
60
|
+
ordered.map((m) => {
|
|
61
|
+
const mine = m.authorId === userId;
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
key={m.id}
|
|
65
|
+
className={mine ? "flex justify-end" : "flex justify-start"}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className={
|
|
69
|
+
"max-w-[80%] rounded-2xl px-3 py-1.5 text-sm " +
|
|
70
|
+
(mine
|
|
71
|
+
? "bg-primary text-primary-foreground"
|
|
72
|
+
: "bg-muted text-foreground")
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
{!mine && (
|
|
76
|
+
<div className="mb-0.5 font-mono text-[10px] text-muted-foreground">
|
|
77
|
+
{shortId(m.authorId)}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<span className="whitespace-pre-wrap break-words">
|
|
81
|
+
{m.text}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
})
|
|
87
|
+
)}
|
|
88
|
+
<div ref={bottomRef} />
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<form
|
|
92
|
+
onSubmit={send}
|
|
93
|
+
className="shrink-0 flex items-center gap-2 border-t py-3"
|
|
94
|
+
>
|
|
95
|
+
<input
|
|
96
|
+
value={text}
|
|
97
|
+
onChange={(e) => setText(e.target.value)}
|
|
98
|
+
placeholder="Message…"
|
|
99
|
+
aria-label="Message"
|
|
100
|
+
autoComplete="off"
|
|
101
|
+
className="flex h-10 w-full rounded-full border border-input bg-transparent px-4 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
102
|
+
/>
|
|
103
|
+
<Button type="submit" disabled={!text.trim()}>
|
|
104
|
+
Send
|
|
105
|
+
</Button>
|
|
106
|
+
</form>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shortId(id: string) {
|
|
112
|
+
return "@" + id.replace(/^guest_/, "").slice(0, 6);
|
|
113
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ErrorBoundaryProps } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
// `app/error.tsx` → the error boundary for this segment. It catches a throw
|
|
6
|
+
// in any page/layout below it and renders at HTTP 500. It's HYDRATED, so
|
|
7
|
+
// this is a real interactive client component: `reset()` re-attempts the
|
|
8
|
+
// route, and useState/onClick work. The thrown error reaches the client as
|
|
9
|
+
// `{ message, digest }` only — the stack stays in the dev overlay
|
|
10
|
+
// (PYLON_DEV_MODE) and the server logs, never in the page.
|
|
11
|
+
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
12
|
+
const [tries, setTries] = React.useState(0);
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<section>
|
|
16
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
17
|
+
Something went wrong
|
|
18
|
+
</h1>
|
|
19
|
+
<p className="mt-2 text-muted-foreground">{error.message}</p>
|
|
20
|
+
{error.digest ? (
|
|
21
|
+
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
22
|
+
Reference: <code>{error.digest}</code>
|
|
23
|
+
</p>
|
|
24
|
+
) : null}
|
|
25
|
+
</section>
|
|
26
|
+
<div className="flex items-center gap-3">
|
|
27
|
+
<Button
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setTries((n) => n + 1);
|
|
30
|
+
reset();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
Try again
|
|
34
|
+
</Button>
|
|
35
|
+
{tries > 0 ? (
|
|
36
|
+
<span className="text-sm text-muted-foreground">
|
|
37
|
+
Retried {tries} {tries === 1 ? "time" : "times"}
|
|
38
|
+
</span>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
/* Tailwind v4 scans these globs for class names. `components/` is here so
|
|
5
|
+
shadcn/ui component classes are seen — add more @source lines if you put
|
|
6
|
+
markup elsewhere. */
|
|
7
|
+
@source "../app/**/*.{tsx,ts,jsx,js}";
|
|
8
|
+
@source "../components/**/*.{tsx,ts,jsx,js}";
|
|
9
|
+
|
|
10
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
11
|
+
|
|
12
|
+
/* shadcn/ui design tokens (new-york / zinc). Edit these to re-theme the
|
|
13
|
+
whole app; `npx shadcn@latest add <component>` drops new components that
|
|
14
|
+
consume the same variables. Toggle dark mode by putting `class="dark"`
|
|
15
|
+
on <html>. */
|
|
16
|
+
:root {
|
|
17
|
+
--radius: 0.625rem;
|
|
18
|
+
--background: oklch(1 0 0);
|
|
19
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
20
|
+
--card: oklch(1 0 0);
|
|
21
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
22
|
+
--popover: oklch(1 0 0);
|
|
23
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
24
|
+
--primary: oklch(0.21 0.006 285.885);
|
|
25
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
26
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
27
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
28
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
29
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
30
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
31
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
32
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
33
|
+
--border: oklch(0.92 0.004 286.32);
|
|
34
|
+
--input: oklch(0.92 0.004 286.32);
|
|
35
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
36
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
37
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
38
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
39
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
40
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
41
|
+
--sidebar: oklch(0.985 0 0);
|
|
42
|
+
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
43
|
+
--sidebar-primary: oklch(0.21 0.006 285.885);
|
|
44
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
45
|
+
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
46
|
+
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
47
|
+
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
48
|
+
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.dark {
|
|
52
|
+
--background: oklch(0.141 0.005 285.823);
|
|
53
|
+
--foreground: oklch(0.985 0 0);
|
|
54
|
+
--card: oklch(0.21 0.006 285.885);
|
|
55
|
+
--card-foreground: oklch(0.985 0 0);
|
|
56
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
57
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
58
|
+
--primary: oklch(0.92 0.004 286.32);
|
|
59
|
+
--primary-foreground: oklch(0.21 0.006 285.885);
|
|
60
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
61
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
62
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
63
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
64
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
65
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
66
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
67
|
+
--border: oklch(1 0 0 / 10%);
|
|
68
|
+
--input: oklch(1 0 0 / 15%);
|
|
69
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
70
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
71
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
72
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
73
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
74
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
75
|
+
--sidebar: oklch(0.21 0.006 285.885);
|
|
76
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
77
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
78
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
79
|
+
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
80
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
81
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
82
|
+
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@theme inline {
|
|
86
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
87
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
88
|
+
--radius-lg: var(--radius);
|
|
89
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
90
|
+
--color-background: var(--background);
|
|
91
|
+
--color-foreground: var(--foreground);
|
|
92
|
+
--color-card: var(--card);
|
|
93
|
+
--color-card-foreground: var(--card-foreground);
|
|
94
|
+
--color-popover: var(--popover);
|
|
95
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
96
|
+
--color-primary: var(--primary);
|
|
97
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
98
|
+
--color-secondary: var(--secondary);
|
|
99
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
100
|
+
--color-muted: var(--muted);
|
|
101
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
102
|
+
--color-accent: var(--accent);
|
|
103
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
104
|
+
--color-destructive: var(--destructive);
|
|
105
|
+
--color-border: var(--border);
|
|
106
|
+
--color-input: var(--input);
|
|
107
|
+
--color-ring: var(--ring);
|
|
108
|
+
--color-chart-1: var(--chart-1);
|
|
109
|
+
--color-chart-2: var(--chart-2);
|
|
110
|
+
--color-chart-3: var(--chart-3);
|
|
111
|
+
--color-chart-4: var(--chart-4);
|
|
112
|
+
--color-chart-5: var(--chart-5);
|
|
113
|
+
--color-sidebar: var(--sidebar);
|
|
114
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
115
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
116
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
117
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
118
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
119
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
120
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@layer base {
|
|
124
|
+
*,
|
|
125
|
+
::after,
|
|
126
|
+
::before,
|
|
127
|
+
::backdrop,
|
|
128
|
+
::file-selector-button {
|
|
129
|
+
border-color: var(--color-border, currentColor);
|
|
130
|
+
outline-color: var(--color-ring);
|
|
131
|
+
}
|
|
132
|
+
body {
|
|
133
|
+
background-color: var(--color-background);
|
|
134
|
+
color: var(--color-foreground);
|
|
135
|
+
}
|
|
136
|
+
button {
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface LayoutProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Full-height layout so the chat can fill the screen with a sticky composer
|
|
8
|
+
// at the bottom. The page renders server-side first, then hydrates into the
|
|
9
|
+
// live room.
|
|
10
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
11
|
+
return (
|
|
12
|
+
<html lang="en" className="h-full">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charSet="utf-8" />
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
16
|
+
<title>__APP_NAME__</title>
|
|
17
|
+
</head>
|
|
18
|
+
<body className="h-full bg-background text-foreground antialiased">
|
|
19
|
+
<div className="mx-auto flex h-full max-w-lg flex-col px-4">
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
|
|
6
|
+
// a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
|
|
7
|
+
// the buttons below use the client router. Not-found boundaries receive the
|
|
8
|
+
// standard page props (and, matching Next, no `reset`).
|
|
9
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
return (
|
|
12
|
+
<div className="space-y-6">
|
|
13
|
+
<section>
|
|
14
|
+
<h1 className="text-2xl font-semibold tracking-tight">404</h1>
|
|
15
|
+
<p className="mt-2 text-muted-foreground">
|
|
16
|
+
We couldn't find that page.
|
|
17
|
+
</p>
|
|
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>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { ChatRoom } from "./chat-client";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "__APP_NAME__ — realtime chat on Pylon",
|
|
7
|
+
description:
|
|
8
|
+
"A live chat room over one Pylon backend — one binary, one port. Open two tabs and watch messages sync instantly.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/page.tsx` → `/`. The header is server-rendered; `<ChatRoom>` is a client
|
|
12
|
+
// island that mints a guest session and runs the live message subscription +
|
|
13
|
+
// optimistic send in the browser.
|
|
14
|
+
export default function IndexPage() {
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<header className="shrink-0 py-4">
|
|
18
|
+
<h1 className="text-xl font-semibold tracking-tight">Room</h1>
|
|
19
|
+
<p className="text-sm text-muted-foreground">
|
|
20
|
+
One shared room. Open a second tab — messages sync instantly.
|
|
21
|
+
</p>
|
|
22
|
+
</header>
|
|
23
|
+
<ChatRoom />
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Robots } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/robots.ts → served at /robots.txt. The default export may also be async.
|
|
4
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
|
+
|
|
6
|
+
export default function robots(): Robots {
|
|
7
|
+
return {
|
|
8
|
+
// Keep the authenticated app and the API out of the index.
|
|
9
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
|
|
10
|
+
sitemap: `${SITE}/sitemap.xml`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
|
|
4
|
+
// it can enumerate dynamic pages from your database. Point SITE_URL at your
|
|
5
|
+
// domain in production.
|
|
6
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
7
|
+
|
|
8
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
9
|
+
// Only public pages belong here — /dashboard is private (and noindex), so
|
|
10
|
+
// it's intentionally left out.
|
|
11
|
+
const staticRoutes: Sitemap = [
|
|
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 },
|
|
15
|
+
];
|
|
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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// A chat message in the shared room. `authorId: field.owner()` stamps the
|
|
11
|
+
// signed-in (guest) user's id server-side, so an optimistic
|
|
12
|
+
// `db.insert("Message", { text })` can't forge the sender. The room is
|
|
13
|
+
// public-read — everyone in it sees every message (that's what makes it a
|
|
14
|
+
// chat room) — while delete is owner-only.
|
|
15
|
+
const Message = entity(
|
|
16
|
+
"Message",
|
|
17
|
+
{
|
|
18
|
+
authorId: field.string().owner(),
|
|
19
|
+
text: field.string(),
|
|
20
|
+
createdAt: field.datetime().defaultNow(),
|
|
21
|
+
},
|
|
22
|
+
{ indexes: [{ name: "by_created", fields: ["createdAt"], unique: false }] },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Public-read so the room renders for everyone; insert requires a session
|
|
26
|
+
// (the owner is stamped by field.owner, see the note on allowInsert); delete
|
|
27
|
+
// is owner-only. An entity with no policy is denied to clients by default.
|
|
28
|
+
const messagePolicy = policy({
|
|
29
|
+
name: "message_room",
|
|
30
|
+
entity: "Message",
|
|
31
|
+
allowRead: "true",
|
|
32
|
+
// `auth.userId != null`, not `== data.authorId`: field.owner() stamps the
|
|
33
|
+
// owner AFTER the policy check, so it's null at insert-time. The stamp still
|
|
34
|
+
// guarantees the message is attributed to the caller.
|
|
35
|
+
allowInsert: "auth.userId != null",
|
|
36
|
+
allowUpdate: "false",
|
|
37
|
+
allowDelete: "auth.userId == data.authorId",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// `pylon dev` serves the SSR room and the realtime API from one port. Guest
|
|
41
|
+
// sessions (via `<EnsureGuest>` on the page) let anyone chat with no login —
|
|
42
|
+
// `db.useQuery("Message")` is a live subscription, so messages appear the
|
|
43
|
+
// instant they're sent, in this tab or another. Natural next steps: a `Room`
|
|
44
|
+
// entity (+ `roomId` on Message) for multiple rooms, and a presence channel
|
|
45
|
+
// (`ctx.connections.*`) for a "who's here" list.
|
|
46
|
+
const manifest = buildManifest({
|
|
47
|
+
name: "__APP_NAME__",
|
|
48
|
+
version: "0.1.0",
|
|
49
|
+
entities: [Message],
|
|
50
|
+
queries: [],
|
|
51
|
+
actions: [],
|
|
52
|
+
policies: [messagePolicy],
|
|
53
|
+
auth: auth(),
|
|
54
|
+
routes: await discoverAppRoutes(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
58
|
+
|
|
59
|
+
export default manifest;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
|
+
outline:
|
|
16
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-4 py-2",
|
|
24
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
25
|
+
lg: "h-10 rounded-md px-8",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps
|
|
37
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
VariantProps<typeof buttonVariants> {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
43
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
44
|
+
const Comp = asChild ? Slot : "button";
|
|
45
|
+
return (
|
|
46
|
+
<Comp
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
ref={ref}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Button.displayName = "Button";
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-xl border bg-card text-card-foreground shadow-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardHeader({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="card-header"
|
|
25
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardTitle({
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="card-title"
|
|
38
|
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CardDescription({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="card-description"
|
|
51
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CardContent({
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-slot="card-content"
|
|
64
|
+
className={cn("p-6 pt-0", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function CardFooter({
|
|
71
|
+
className,
|
|
72
|
+
...props
|
|
73
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="card-footer"
|
|
77
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
Card,
|
|
85
|
+
CardHeader,
|
|
86
|
+
CardFooter,
|
|
87
|
+
CardTitle,
|
|
88
|
+
CardDescription,
|
|
89
|
+
CardContent,
|
|
90
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Server functions go here. Each file in this directory that exports a
|
|
2
|
+
// query() or action() becomes a typed RPC endpoint, callable from your
|
|
3
|
+
// pages and client with full type inference. Delete this placeholder when
|
|
4
|
+
// you add your first one.
|
|
5
|
+
//
|
|
6
|
+
// Example (functions/notes.ts):
|
|
7
|
+
//
|
|
8
|
+
// import { query } from "@pylonsync/functions";
|
|
9
|
+
//
|
|
10
|
+
// export const listNotes = query(async (ctx) => {
|
|
11
|
+
// return ctx.db.list("Note");
|
|
12
|
+
// });
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
// `cn` — the shadcn class merger. clsx resolves conditional/array class
|
|
5
|
+
// inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
|
|
6
|
+
// the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
|
|
7
|
+
// component routes its className through this.
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|