@pylonsync/create-pylon 0.3.266 → 0.3.268
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 +77 -14
- 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/app.ts +3 -0
- package/templates/todo/AGENTS.md +61 -0
- package/templates/todo/README.md +59 -0
- package/templates/todo/app/error.tsx +43 -0
- package/templates/todo/app/globals.css +139 -0
- package/templates/todo/app/layout.tsx +31 -0
- package/templates/todo/app/not-found.tsx +29 -0
- package/templates/todo/app/page.tsx +37 -0
- package/templates/todo/app/robots.ts +12 -0
- package/templates/todo/app/sitemap.ts +27 -0
- package/templates/todo/app/todo-app.tsx +133 -0
- package/templates/todo/app.ts +72 -0
- package/templates/todo/components/ui/button.tsx +56 -0
- package/templates/todo/components/ui/card.tsx +90 -0
- package/templates/todo/components.json +20 -0
- package/templates/todo/functions/_keep.ts +13 -0
- package/templates/todo/gitignore +10 -0
- package/templates/todo/lib/utils.ts +10 -0
- package/templates/todo/package.json +33 -0
- package/templates/todo/tsconfig.json +18 -0
|
@@ -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,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// A layout wraps every page. This one is intentionally minimal — a header
|
|
4
|
+
// and a centered column. The page below it is server-rendered first (so the
|
|
5
|
+
// shell and copy are in the HTML), then hydrates into the live todo UI.
|
|
6
|
+
interface LayoutProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
11
|
+
// Add `className="dark"` to this <html> to flip every shadcn token to its
|
|
12
|
+
// dark value. The classes below use semantic tokens (bg-background,
|
|
13
|
+
// text-foreground, …) so the whole UI re-themes from app/globals.css.
|
|
14
|
+
return (
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charSet="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
19
|
+
<title>__APP_NAME__</title>
|
|
20
|
+
{/* Tailwind is compiled by Pylon from app/globals.css and the
|
|
21
|
+
stylesheet link is injected here automatically — nothing to
|
|
22
|
+
wire up. */}
|
|
23
|
+
</head>
|
|
24
|
+
<body className="min-h-screen bg-background text-foreground antialiased">
|
|
25
|
+
<main className="mx-auto flex min-h-screen max-w-xl flex-col px-4 py-12">
|
|
26
|
+
{children}
|
|
27
|
+
</main>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -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,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { TodoApp } from "./todo-app";
|
|
4
|
+
|
|
5
|
+
// SEO metadata. Export `metadata` (static) or `generateMetadata(props)`
|
|
6
|
+
// (dynamic) from any page or layout — Pylon renders the <title>/<meta>
|
|
7
|
+
// into <head> server-side. The `Metadata` type is exported from
|
|
8
|
+
// @pylonsync/react.
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
title: "__APP_NAME__ — a live Pylon todo",
|
|
11
|
+
description:
|
|
12
|
+
"A server-rendered todo list with live, optimistic, per-user sync — one binary, one port. Open two tabs and watch them stay in sync.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// `app/page.tsx` → `/`. The heading and intro are server-rendered (view
|
|
16
|
+
// source and they're in the HTML — good for SEO and first paint). The list
|
|
17
|
+
// itself is a client island: `<TodoApp>` mints a guest session and runs the
|
|
18
|
+
// live query + optimistic writes in the browser.
|
|
19
|
+
export default function IndexPage() {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-1 flex-col">
|
|
22
|
+
<header className="mb-8">
|
|
23
|
+
<h1 className="text-3xl font-semibold tracking-tight">Todos</h1>
|
|
24
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
25
|
+
Live and optimistic over one Pylon backend. Open a second tab —
|
|
26
|
+
changes sync instantly.
|
|
27
|
+
</p>
|
|
28
|
+
</header>
|
|
29
|
+
<TodoApp />
|
|
30
|
+
<p className="mt-8 text-xs text-muted-foreground">
|
|
31
|
+
Edit <code className="rounded bg-muted px-1">app/todo-app.tsx</code> for
|
|
32
|
+
the UI, or <code className="rounded bg-muted px-1">app.ts</code> for the
|
|
33
|
+
data model and access policies.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -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,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest } from "@pylonsync/client";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
|
|
8
|
+
export interface Todo {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
done: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// `<EnsureGuest>` mints a guest session (POST /api/auth/guest) on first load
|
|
15
|
+
// if the browser doesn't already have one, so the list works with no login —
|
|
16
|
+
// every visitor implicitly becomes their own user and their todos stay
|
|
17
|
+
// private. The list itself is gated to the owner by the policy in app.ts.
|
|
18
|
+
export function TodoApp() {
|
|
19
|
+
return (
|
|
20
|
+
<EnsureGuest fallback={<ListSkeleton />}>
|
|
21
|
+
<TodoList />
|
|
22
|
+
</EnsureGuest>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `db.useQuery` is a LIVE subscription — it re-renders the instant a Todo is
|
|
27
|
+
// added, toggled, or removed, in this tab or another. `db.insert` /
|
|
28
|
+
// `db.update` / `db.delete` are OPTIMISTIC: they apply to the local store
|
|
29
|
+
// immediately (zero-latency UI) and sync in the background, rolling back
|
|
30
|
+
// automatically if a policy rejects the write.
|
|
31
|
+
function TodoList() {
|
|
32
|
+
const [title, setTitle] = useState("");
|
|
33
|
+
const { data: todos, loading } = db.useQuery<Todo>("Todo");
|
|
34
|
+
|
|
35
|
+
async function addTodo(e: React.FormEvent) {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
const text = title.trim();
|
|
38
|
+
if (!text) return;
|
|
39
|
+
setTitle("");
|
|
40
|
+
// We don't send userId — `field.owner()` stamps it from the session
|
|
41
|
+
// server-side and rejects any forged value, so this optimistic insert is
|
|
42
|
+
// safe.
|
|
43
|
+
await db.insert("Todo", { title: text, done: false });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const remaining = todos.filter((t) => !t.done).length;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-5">
|
|
50
|
+
<form onSubmit={addTodo} className="flex items-center gap-2">
|
|
51
|
+
<input
|
|
52
|
+
value={title}
|
|
53
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
54
|
+
placeholder="Add a todo…"
|
|
55
|
+
aria-label="Todo"
|
|
56
|
+
autoComplete="off"
|
|
57
|
+
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
58
|
+
/>
|
|
59
|
+
<Button type="submit">Add</Button>
|
|
60
|
+
</form>
|
|
61
|
+
|
|
62
|
+
{loading && todos.length === 0 ? (
|
|
63
|
+
<ListSkeleton />
|
|
64
|
+
) : todos.length === 0 ? (
|
|
65
|
+
<p className="text-sm text-muted-foreground">
|
|
66
|
+
Nothing yet — add a todo above. It appears instantly (optimistic) and
|
|
67
|
+
syncs; open this page in a second tab to watch it arrive live.
|
|
68
|
+
</p>
|
|
69
|
+
) : (
|
|
70
|
+
<>
|
|
71
|
+
<ul className="space-y-2">
|
|
72
|
+
{todos.map((todo) => (
|
|
73
|
+
<li
|
|
74
|
+
key={todo.id}
|
|
75
|
+
className="flex items-center gap-3 rounded-md border px-3 py-2.5 text-sm"
|
|
76
|
+
>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
aria-label={todo.done ? "Mark not done" : "Mark done"}
|
|
80
|
+
onClick={() => db.update("Todo", todo.id, { done: !todo.done })}
|
|
81
|
+
className={
|
|
82
|
+
"flex size-5 items-center justify-center rounded-full border text-xs transition-colors " +
|
|
83
|
+
(todo.done
|
|
84
|
+
? "border-emerald-600 bg-emerald-600 text-white"
|
|
85
|
+
: "border-muted-foreground/30 text-transparent hover:border-muted-foreground")
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
✓
|
|
89
|
+
</button>
|
|
90
|
+
<span
|
|
91
|
+
className={
|
|
92
|
+
todo.done
|
|
93
|
+
? "flex-1 text-muted-foreground line-through"
|
|
94
|
+
: "flex-1"
|
|
95
|
+
}
|
|
96
|
+
>
|
|
97
|
+
{todo.title}
|
|
98
|
+
</span>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
aria-label="Delete todo"
|
|
102
|
+
onClick={() => db.delete("Todo", todo.id)}
|
|
103
|
+
className="text-muted-foreground/40 transition-colors hover:text-red-600"
|
|
104
|
+
>
|
|
105
|
+
✕
|
|
106
|
+
</button>
|
|
107
|
+
</li>
|
|
108
|
+
))}
|
|
109
|
+
</ul>
|
|
110
|
+
<p className="text-xs text-muted-foreground">
|
|
111
|
+
{remaining} {remaining === 1 ? "todo" : "todos"} left
|
|
112
|
+
</p>
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ListSkeleton() {
|
|
120
|
+
return (
|
|
121
|
+
<ul className="space-y-2" aria-hidden>
|
|
122
|
+
{[0, 1, 2].map((i) => (
|
|
123
|
+
<li
|
|
124
|
+
key={i}
|
|
125
|
+
className="flex items-center gap-3 rounded-md border px-3 py-2.5"
|
|
126
|
+
>
|
|
127
|
+
<span className="size-5 rounded-full bg-muted" />
|
|
128
|
+
<span className="h-4 flex-1 rounded bg-muted" />
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// A todo that belongs to one person. `userId: field.owner()` is the key
|
|
11
|
+
// move: the framework stamps the signed-in (here: guest) user's id
|
|
12
|
+
// server-side on insert and rejects any forged value — so the UI can do a
|
|
13
|
+
// plain, optimistic `db.insert("Todo", { title })` (the row shows instantly,
|
|
14
|
+
// no round-trip) while ownership stays unspoofable. No createTodo function to
|
|
15
|
+
// write — every verb is a direct, policy-checked entity call.
|
|
16
|
+
const Todo = entity(
|
|
17
|
+
"Todo",
|
|
18
|
+
{
|
|
19
|
+
userId: field.string().owner(),
|
|
20
|
+
title: field.string(),
|
|
21
|
+
done: field.boolean().default(false),
|
|
22
|
+
// Float sort key so a drag-reorder can drop a row between two others by
|
|
23
|
+
// taking their midpoint — no renumbering the whole list. New rows seed
|
|
24
|
+
// with createdAt so the default order is chronological.
|
|
25
|
+
sortKey: field.float().optional(),
|
|
26
|
+
createdAt: field.datetime().defaultNow(),
|
|
27
|
+
},
|
|
28
|
+
{ indexes: [{ name: "by_user", fields: ["userId"], unique: false }] },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Todos are private — every read and write is gated to the owner. An entity
|
|
32
|
+
// with NO policy is denied to clients by default, so this is exactly what
|
|
33
|
+
// makes the live query + optimistic writes work, and only for your own rows.
|
|
34
|
+
// `auth.userId` is the session user (a guest id here); `data.userId` is the
|
|
35
|
+
// row's owner.
|
|
36
|
+
const todoPolicy = policy({
|
|
37
|
+
name: "todo_access",
|
|
38
|
+
entity: "Todo",
|
|
39
|
+
allowRead: "auth.userId == data.userId",
|
|
40
|
+
allowInsert: "auth.userId != null",
|
|
41
|
+
allowUpdate: "auth.userId == data.userId",
|
|
42
|
+
allowDelete: "auth.userId == data.userId",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The manifest is your whole app in one object: data, policies, and the
|
|
46
|
+
// file-based routes under `app/`. `pylon dev` reads this, serves the SSR
|
|
47
|
+
// frontend and the API from one port, and regenerates a typed client on
|
|
48
|
+
// every change.
|
|
49
|
+
//
|
|
50
|
+
// This starter uses guest sessions: the page wraps its UI in `<EnsureGuest>`,
|
|
51
|
+
// which POSTs `/api/auth/guest` on first load so every visitor implicitly
|
|
52
|
+
// becomes their own user — no login wall, todos still private per browser.
|
|
53
|
+
// To require real accounts instead, enable email/password (it's built in
|
|
54
|
+
// against a `User` entity) and swap `<EnsureGuest>` for `<SignedIn>` /
|
|
55
|
+
// `<SignedOut>` from `@pylonsync/client`.
|
|
56
|
+
const manifest = buildManifest({
|
|
57
|
+
name: "__APP_NAME__",
|
|
58
|
+
version: "0.1.0",
|
|
59
|
+
entities: [Todo],
|
|
60
|
+
queries: [],
|
|
61
|
+
actions: [],
|
|
62
|
+
policies: [todoPolicy],
|
|
63
|
+
auth: auth(),
|
|
64
|
+
// File-based routing: `discoverAppRoutes()` walks `app/**/page.tsx` and
|
|
65
|
+
// emits one route per page. Drop `app/about/page.tsx` to add `/about`.
|
|
66
|
+
routes: await discoverAppRoutes(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Emit canonical manifest JSON to stdout for `pylon codegen`.
|
|
70
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
71
|
+
|
|
72
|
+
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 {};
|