@vibiz/cli 0.0.0-stage → 0.1.0
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/README.md +36 -2
- package/dist/chunk-NWL4LVVI.js +1015 -0
- package/dist/src-BOTTM5XC.js +2230 -0
- package/dist/vibiz.js +3302 -0
- package/package.json +37 -4
- package/templates/nextjs-saas/.env.example +13 -0
- package/templates/nextjs-saas/README.md +106 -0
- package/templates/nextjs-saas/_gitignore +8 -0
- package/templates/nextjs-saas/_npmrc +1 -0
- package/templates/nextjs-saas/app/api/auth/[...path]/route.ts +30 -0
- package/templates/nextjs-saas/app/api/chat/route.ts +76 -0
- package/templates/nextjs-saas/app/dashboard/actions.ts +32 -0
- package/templates/nextjs-saas/app/dashboard/page.tsx +130 -0
- package/templates/nextjs-saas/app/globals.css +17 -0
- package/templates/nextjs-saas/app/layout.tsx +28 -0
- package/templates/nextjs-saas/app/not-found.tsx +13 -0
- package/templates/nextjs-saas/app/page.tsx +63 -0
- package/templates/nextjs-saas/app/sign-in/page.tsx +8 -0
- package/templates/nextjs-saas/app/sign-up/page.tsx +8 -0
- package/templates/nextjs-saas/app.config.ts +15 -0
- package/templates/nextjs-saas/components/analytics.tsx +20 -0
- package/templates/nextjs-saas/components/auth-form.tsx +144 -0
- package/templates/nextjs-saas/components/auth-page.tsx +30 -0
- package/templates/nextjs-saas/components/chat-box.tsx +95 -0
- package/templates/nextjs-saas/components/header.tsx +24 -0
- package/templates/nextjs-saas/components/not-configured.tsx +17 -0
- package/templates/nextjs-saas/components/replay-consent.tsx +75 -0
- package/templates/nextjs-saas/components/replay-identify.tsx +21 -0
- package/templates/nextjs-saas/components/sign-out-button.tsx +25 -0
- package/templates/nextjs-saas/components/ui.tsx +47 -0
- package/templates/nextjs-saas/db/migrate.ts +21 -0
- package/templates/nextjs-saas/db/schema.ts +18 -0
- package/templates/nextjs-saas/lib/auth/client.ts +6 -0
- package/templates/nextjs-saas/lib/auth/server.ts +44 -0
- package/templates/nextjs-saas/lib/db.ts +32 -0
- package/templates/nextjs-saas/lib/env.ts +36 -0
- package/templates/nextjs-saas/lib/replay.ts +55 -0
- package/templates/nextjs-saas/next.config.ts +9 -0
- package/templates/nextjs-saas/package-lock.json +5359 -0
- package/templates/nextjs-saas/package.json +32 -0
- package/templates/nextjs-saas/postcss.config.mjs +3 -0
- package/templates/nextjs-saas/proxy.ts +18 -0
- package/templates/nextjs-saas/tsconfig.json +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibiz/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Vibiz Stack CLI and MCP server: website, database, sign-in, email and AI for a new business, from your terminal or your AI agent.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vibiz",
|
|
7
|
+
"cli",
|
|
8
|
+
"mcp",
|
|
9
|
+
"deploy",
|
|
10
|
+
"hosting"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://app.vibiz.app/docs",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/Vibiz-ai/vibiz-stack.git",
|
|
16
|
+
"directory": "packages/cli"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/Vibiz-ai/vibiz-stack/issues"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"vibiz": "./dist/vibiz.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"templates"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
37
|
+
"zod": "4.6.7"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# `vibiz dev` writes the development values to .env.development.local. Never commit real values.
|
|
2
|
+
DATABASE_URL=
|
|
3
|
+
DIRECT_URL=
|
|
4
|
+
VIBIZ_AUTH_BASE_URL=
|
|
5
|
+
VIBIZ_AUTH_COOKIE_SECRET=
|
|
6
|
+
# Optional: site analytics. When both are set, the analytics script loads.
|
|
7
|
+
NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY=
|
|
8
|
+
NEXT_PUBLIC_VIBIZ_ANALYTICS_URL=
|
|
9
|
+
# Optional: session replay (the `replay` resource). Asks visitors for consent first.
|
|
10
|
+
NEXT_PUBLIC_VIBIZ_REPLAY=
|
|
11
|
+
# Optional: the AI assistant on the dashboard (`vibiz add ai`). OPENAI_* are aliases.
|
|
12
|
+
VIBIZ_AI_BASE_URL=
|
|
13
|
+
VIBIZ_AI_API_KEY=
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# nextjs-saas
|
|
2
|
+
|
|
3
|
+
The Vibiz Stack starter app: a landing page, sign-in (email and Google), a protected dashboard
|
|
4
|
+
that saves notes to your database, and sign-out. Next.js (App Router), TypeScript, Tailwind,
|
|
5
|
+
Drizzle.
|
|
6
|
+
|
|
7
|
+
## Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
vibiz login --key vz_sk_…
|
|
11
|
+
mkdir acme && cd acme
|
|
12
|
+
vibiz init "Acme" --template nextjs-saas # copies this app, names it, creates the project
|
|
13
|
+
npm install
|
|
14
|
+
vibiz dev # a development database, sign-in and AI key
|
|
15
|
+
npm run dev # http://localhost:3000
|
|
16
|
+
vibiz deploy # preview URL
|
|
17
|
+
vibiz promote <deployment> # live
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`vibiz init` refuses a folder that isn't empty; pass `--force` to copy into one anyway (files
|
|
21
|
+
with the same name are overwritten).
|
|
22
|
+
|
|
23
|
+
`vibiz dev` writes the development environment into `.env.development.local` (keeping any other
|
|
24
|
+
lines already in it): a database branch with your schema and none of the live app's rows, its own
|
|
25
|
+
sign-in and its own AI key. Production values never reach your laptop or your agent: the live app
|
|
26
|
+
gets them from Vibiz directly. Run `vibiz dev` again whenever `vibiz add` adds a resource, and
|
|
27
|
+
`vibiz dev --reset` for a clean copy. `vibiz dev -- npm run dev` runs with the values without
|
|
28
|
+
writing a file. `.env*` files are never uploaded by `vibiz deploy`.
|
|
29
|
+
|
|
30
|
+
Without the env, the app still runs: pages that need sign-in or the database say they aren't set
|
|
31
|
+
up yet, and the server log names the missing variables.
|
|
32
|
+
|
|
33
|
+
## Make it yours
|
|
34
|
+
|
|
35
|
+
- **Name, pitch and colors**: `app.config.ts`. Everything else reads from it.
|
|
36
|
+
- **Landing page**: `app/page.tsx`.
|
|
37
|
+
- **After sign-in**: `AFTER_SIGN_IN` in `components/auth-form.tsx` (default `/dashboard`).
|
|
38
|
+
- **Protected routes**: the `matcher` in `proxy.ts`. Pages should still check `currentUser()`.
|
|
39
|
+
|
|
40
|
+
## AI
|
|
41
|
+
|
|
42
|
+
`vibiz add ai` gives the app a project AI key: `VIBIZ_AI_BASE_URL` and `VIBIZ_AI_API_KEY` (also
|
|
43
|
+
as `OPENAI_BASE_URL` / `OPENAI_API_KEY`, so `new OpenAI()` works as is). The dashboard's
|
|
44
|
+
assistant (`components/chat-box.tsx`) streams from `app/api/chat/route.ts`, which uses the plain
|
|
45
|
+
OpenAI SDK with the model `vibiz/fast`. Calls are metered against the project: set a daily budget
|
|
46
|
+
with `PATCH /v1/projects/:id/ai`. Keep AI routes behind sign-in, as this one is.
|
|
47
|
+
|
|
48
|
+
## Data
|
|
49
|
+
|
|
50
|
+
Tables are declared in `db/schema.ts` and created on first use by `db/migrate.ts`
|
|
51
|
+
(`CREATE TABLE IF NOT EXISTS`, once per server process). To add a table, declare it in the schema
|
|
52
|
+
and append its idempotent `CREATE … IF NOT EXISTS` statement to `db/migrate.ts`. When the schema
|
|
53
|
+
grows past that, switch to generated migrations with `drizzle-kit`.
|
|
54
|
+
|
|
55
|
+
Users are stored by the sign-in service in its own schema. Reference them by id (`text`), as
|
|
56
|
+
`notes.user_id` does; don't declare that schema in Drizzle.
|
|
57
|
+
|
|
58
|
+
## Layout
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
app.config.ts name, tagline, colors
|
|
62
|
+
app/page.tsx landing page
|
|
63
|
+
app/sign-in, app/sign-up email + "Continue with Google"
|
|
64
|
+
app/dashboard signed-in user and their notes (server actions)
|
|
65
|
+
app/api/auth/[...path] forwards sign-in requests to the sign-in service
|
|
66
|
+
proxy.ts guards /dashboard, finishes Google sign-in
|
|
67
|
+
lib/auth/ server (getAuth, currentUser) and browser (authClient) handles
|
|
68
|
+
lib/db.ts, db/ Postgres via Drizzle, schema, create-if-missing
|
|
69
|
+
components/analytics.tsx loads site analytics when NEXT_PUBLIC_VIBIZ_ANALYTICS_* are set
|
|
70
|
+
components/replay-*.tsx session replay: consent banner, `always` for signed-in users
|
|
71
|
+
lib/replay.ts the consent choice (localStorage) and vibiz('replay', …)
|
|
72
|
+
app/api/chat, components/chat-box.tsx the AI assistant (streams from the Vibiz AI gateway)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Env
|
|
76
|
+
|
|
77
|
+
| Variable | Used for |
|
|
78
|
+
| --------------------------------- | ----------------------------------------- |
|
|
79
|
+
| `DATABASE_URL` | queries (pooled) |
|
|
80
|
+
| `DIRECT_URL` | migrations tooling, if you add it |
|
|
81
|
+
| `VIBIZ_AUTH_BASE_URL` | the sign-in service |
|
|
82
|
+
| `VIBIZ_AUTH_COOKIE_SECRET` | signs the session cookie (32+ chars) |
|
|
83
|
+
| `NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY` | optional: analytics, with the URL below |
|
|
84
|
+
| `NEXT_PUBLIC_VIBIZ_ANALYTICS_URL` | optional: loads `<url>/v1/script.js` |
|
|
85
|
+
| `NEXT_PUBLIC_VIBIZ_REPLAY` | optional: session replay, after consent |
|
|
86
|
+
|
|
87
|
+
The other sign-in variables Vibiz sets (`VIBIZ_AUTH_JWKS_URL`, `VIBIZ_AUTH_PROJECT_ID`, keys) are
|
|
88
|
+
there for code that verifies tokens itself; this app doesn't need them.
|
|
89
|
+
|
|
90
|
+
## Where sign-in works
|
|
91
|
+
|
|
92
|
+
Sign-in only accepts requests from the app's own addresses: its live address
|
|
93
|
+
(`https://<slug>.vibiz.app`), any domain an owner added (`vibiz domains add app.example.com`, for
|
|
94
|
+
hosting the app somewhere else), and, in the development environment, `http://localhost:3000`.
|
|
95
|
+
Preview deployment URLs are not trusted yet: if sign-in answers "invalid origin" on one, promote
|
|
96
|
+
it and sign in on the live address. Everything else (pages, the database) works anywhere.
|
|
97
|
+
|
|
98
|
+
## Session replay
|
|
99
|
+
|
|
100
|
+
With the `replay` resource (`vibiz add replay`), the app asks each visitor once, in a small banner,
|
|
101
|
+
whether the visit may be recorded to find and fix problems. Nothing is recorded before they say yes,
|
|
102
|
+
and the choice is kept in `localStorage`. "Recording choices" in the footer asks again. What people
|
|
103
|
+
type, passwords and card fields are never in a recording. Signed-in users on `/dashboard` are
|
|
104
|
+
recorded for the whole tab (`vibiz('replay', 'always')`); everyone else only when something goes
|
|
105
|
+
wrong (an error, a rage or dead click). Mark anything else private with `data-vibiz-mask` (text
|
|
106
|
+
hidden) or `data-vibiz-block` (not recorded at all).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
save-exact=true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AuthNotConfiguredError, getAuth } from '@/lib/auth/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every `/api/auth/*` call is forwarded to the sign-in service. The handler is
|
|
5
|
+
* resolved per request so missing env answers 503 with a clear message.
|
|
6
|
+
*/
|
|
7
|
+
type Ctx = { params: Promise<{ path: string[] }> };
|
|
8
|
+
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
9
|
+
|
|
10
|
+
function forward(method: Method) {
|
|
11
|
+
return (request: Request, ctx: Ctx) => {
|
|
12
|
+
try {
|
|
13
|
+
return getAuth().handler()[method](request, ctx);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err instanceof AuthNotConfiguredError) {
|
|
16
|
+
return Response.json(
|
|
17
|
+
{ error: { code: 'auth_not_configured', message: err.message } },
|
|
18
|
+
{ status: 503 },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const GET = forward('GET');
|
|
27
|
+
export const POST = forward('POST');
|
|
28
|
+
export const PUT = forward('PUT');
|
|
29
|
+
export const DELETE = forward('DELETE');
|
|
30
|
+
export const PATCH = forward('PATCH');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { currentUser } from '@/lib/auth/server';
|
|
3
|
+
import { missingAiEnv } from '@/lib/env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A chat endpoint on the Vibiz AI gateway, through the plain OpenAI SDK:
|
|
7
|
+
* `VIBIZ_AI_BASE_URL` and `VIBIZ_AI_API_KEY` come with the `ai` resource
|
|
8
|
+
* (`vibiz add ai`), and every call is metered against the project's budget.
|
|
9
|
+
*
|
|
10
|
+
* Signed-in users only, short history, bounded answers: a public endpoint that
|
|
11
|
+
* calls a model is an open tap on your AI budget.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAX_MESSAGES = 20;
|
|
15
|
+
const MAX_CHARS = 4_000;
|
|
16
|
+
|
|
17
|
+
type Message = { role: 'user' | 'assistant'; content: string };
|
|
18
|
+
|
|
19
|
+
function parseMessages(body: unknown): Message[] | null {
|
|
20
|
+
const list = (body as { messages?: unknown })?.messages;
|
|
21
|
+
if (!Array.isArray(list) || list.length === 0) return null;
|
|
22
|
+
const messages = list.slice(-MAX_MESSAGES).map((m) => ({
|
|
23
|
+
role: (m as Message).role === 'assistant' ? 'assistant' : 'user',
|
|
24
|
+
content: String((m as Message).content ?? '').slice(0, MAX_CHARS),
|
|
25
|
+
})) as Message[];
|
|
26
|
+
return messages.every((m) => m.content) ? messages : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function POST(req: Request) {
|
|
30
|
+
if (missingAiEnv().length) return Response.json({ error: 'AI is not set up.' }, { status: 503 });
|
|
31
|
+
if (!(await currentUser())) return Response.json({ error: 'Sign in first.' }, { status: 401 });
|
|
32
|
+
const messages = parseMessages(await req.json().catch(() => null));
|
|
33
|
+
if (!messages) return Response.json({ error: 'Send { messages: [...] }.' }, { status: 400 });
|
|
34
|
+
|
|
35
|
+
const openai = new OpenAI({
|
|
36
|
+
baseURL: process.env.VIBIZ_AI_BASE_URL,
|
|
37
|
+
apiKey: process.env.VIBIZ_AI_API_KEY,
|
|
38
|
+
});
|
|
39
|
+
const stream = await openai.chat.completions
|
|
40
|
+
.create(
|
|
41
|
+
{
|
|
42
|
+
model: 'vibiz/fast',
|
|
43
|
+
messages: [{ role: 'system', content: 'You are a helpful, concise assistant.' }, ...messages],
|
|
44
|
+
max_tokens: 500,
|
|
45
|
+
stream: true,
|
|
46
|
+
},
|
|
47
|
+
// The visitor closing the tab cancels the model call: nobody pays for unread tokens.
|
|
48
|
+
{ signal: req.signal },
|
|
49
|
+
)
|
|
50
|
+
.catch((err: unknown) => (err instanceof Error ? err : new Error(String(err))));
|
|
51
|
+
if (stream instanceof Error) {
|
|
52
|
+
const status = stream instanceof OpenAI.APIError && stream.status === 429 ? 429 : 502;
|
|
53
|
+
return Response.json({ error: 'The assistant is unavailable right now.' }, { status });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
return new Response(
|
|
58
|
+
new ReadableStream({
|
|
59
|
+
async pull(controller) {
|
|
60
|
+
try {
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
const text = chunk.choices[0]?.delta.content;
|
|
63
|
+
if (text) controller.enqueue(encoder.encode(text));
|
|
64
|
+
}
|
|
65
|
+
controller.close();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
controller.error(err);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
cancel() {
|
|
71
|
+
stream.controller.abort();
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
{ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
|
+
import { revalidatePath } from 'next/cache';
|
|
5
|
+
import { notes } from '@/db/schema';
|
|
6
|
+
import { currentUser } from '@/lib/auth/server';
|
|
7
|
+
import { getDb } from '@/lib/db';
|
|
8
|
+
|
|
9
|
+
async function requireUser() {
|
|
10
|
+
const user = await currentUser();
|
|
11
|
+
if (!user) throw new Error('Not signed in');
|
|
12
|
+
return user;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function addNote(formData: FormData) {
|
|
16
|
+
const user = await requireUser();
|
|
17
|
+
const body = String(formData.get('body') ?? '').trim().slice(0, 500);
|
|
18
|
+
if (!body) return;
|
|
19
|
+
const db = await getDb();
|
|
20
|
+
await db.insert(notes).values({ userId: user.id, body });
|
|
21
|
+
revalidatePath('/dashboard');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function deleteNote(formData: FormData) {
|
|
25
|
+
const user = await requireUser();
|
|
26
|
+
const id = Number(formData.get('id'));
|
|
27
|
+
if (!Number.isInteger(id)) return;
|
|
28
|
+
const db = await getDb();
|
|
29
|
+
// Scoped by user: nobody can delete someone else's note by guessing an id.
|
|
30
|
+
await db.delete(notes).where(and(eq(notes.id, id), eq(notes.userId, user.id)));
|
|
31
|
+
revalidatePath('/dashboard');
|
|
32
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import type { Metadata } from 'next';
|
|
3
|
+
import { redirect } from 'next/navigation';
|
|
4
|
+
import { connection } from 'next/server';
|
|
5
|
+
import { type Note, notes } from '@/db/schema';
|
|
6
|
+
import { ChatBox } from '@/components/chat-box';
|
|
7
|
+
import { Header } from '@/components/header';
|
|
8
|
+
import { NotConfigured } from '@/components/not-configured';
|
|
9
|
+
import { ReplaySignedIn } from '@/components/replay-identify';
|
|
10
|
+
import { SignOutButton } from '@/components/sign-out-button';
|
|
11
|
+
import { Button, Input, Notice } from '@/components/ui';
|
|
12
|
+
import { currentUser } from '@/lib/auth/server';
|
|
13
|
+
import { getDb } from '@/lib/db';
|
|
14
|
+
import { missingAiEnv, missingAuthEnv, missingDatabaseEnv } from '@/lib/env';
|
|
15
|
+
import { addNote, deleteNote } from './actions';
|
|
16
|
+
|
|
17
|
+
export const metadata: Metadata = { title: 'Dashboard' };
|
|
18
|
+
|
|
19
|
+
async function loadNotes(userId: string): Promise<{ notes: Note[] } | { error: string }> {
|
|
20
|
+
try {
|
|
21
|
+
const db = await getDb();
|
|
22
|
+
const rows = await db
|
|
23
|
+
.select()
|
|
24
|
+
.from(notes)
|
|
25
|
+
.where(eq(notes.userId, userId))
|
|
26
|
+
.orderBy(desc(notes.createdAt))
|
|
27
|
+
.limit(50);
|
|
28
|
+
return { notes: rows };
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('[dashboard] could not load notes', err);
|
|
31
|
+
return { error: 'We could not reach the database. Please try again in a moment.' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function Dashboard() {
|
|
36
|
+
await connection();
|
|
37
|
+
|
|
38
|
+
if (missingAuthEnv().length) {
|
|
39
|
+
return (
|
|
40
|
+
<Shell>
|
|
41
|
+
<NotConfigured what="Sign-in" />
|
|
42
|
+
</Shell>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const user = await currentUser();
|
|
47
|
+
if (!user) redirect('/sign-in');
|
|
48
|
+
|
|
49
|
+
const dbReady = missingDatabaseEnv().length === 0;
|
|
50
|
+
const result = dbReady ? await loadNotes(user.id) : null;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Shell signedIn>
|
|
54
|
+
<ReplaySignedIn />
|
|
55
|
+
<section className="flex items-center gap-4 rounded-xl border border-zinc-200 p-6">
|
|
56
|
+
{user.image ? (
|
|
57
|
+
// A plain <img>: avatars come from any host, so next/image would need them all listed.
|
|
58
|
+
<img src={user.image} alt="" className="size-12 rounded-full" />
|
|
59
|
+
) : (
|
|
60
|
+
<span className="grid size-12 place-items-center rounded-full bg-brand text-lg font-medium text-brand-foreground">
|
|
61
|
+
{(user.name || user.email).slice(0, 1).toUpperCase()}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
<div className="min-w-0">
|
|
65
|
+
<p className="truncate font-medium">{user.name || 'Welcome'}</p>
|
|
66
|
+
<p className="truncate text-sm text-zinc-600">{user.email}</p>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<section className="mt-8">
|
|
71
|
+
<h2 className="text-lg font-semibold tracking-tight">Your notes</h2>
|
|
72
|
+
<p className="mt-1 text-sm text-zinc-600">Saved to your account. Only you can see them.</p>
|
|
73
|
+
|
|
74
|
+
<div className="mt-4">
|
|
75
|
+
{!dbReady ? (
|
|
76
|
+
<NotConfigured what="The database" />
|
|
77
|
+
) : result && 'error' in result ? (
|
|
78
|
+
<Notice title="Something went wrong">{result.error}</Notice>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
<form action={addNote} className="flex gap-2">
|
|
82
|
+
<Input name="body" required maxLength={500} placeholder="Write a note…" aria-label="New note" />
|
|
83
|
+
<Button type="submit">Add</Button>
|
|
84
|
+
</form>
|
|
85
|
+
<ul className="mt-4 divide-y divide-zinc-200 rounded-xl border border-zinc-200">
|
|
86
|
+
{result?.notes.length ? (
|
|
87
|
+
result.notes.map((n) => (
|
|
88
|
+
<li key={n.id} className="flex items-start justify-between gap-4 px-4 py-3">
|
|
89
|
+
<div className="min-w-0">
|
|
90
|
+
<p className="text-sm break-words">{n.body}</p>
|
|
91
|
+
<p className="mt-0.5 text-xs text-zinc-500">
|
|
92
|
+
{n.createdAt.toLocaleString('en', { dateStyle: 'medium', timeStyle: 'short' })}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<form action={deleteNote}>
|
|
96
|
+
<input type="hidden" name="id" value={n.id} />
|
|
97
|
+
<button type="submit" className="text-xs text-zinc-500 hover:text-red-600">
|
|
98
|
+
Delete
|
|
99
|
+
</button>
|
|
100
|
+
</form>
|
|
101
|
+
</li>
|
|
102
|
+
))
|
|
103
|
+
) : (
|
|
104
|
+
<li className="px-4 py-8 text-center text-sm text-zinc-500">No notes yet. Add your first one above.</li>
|
|
105
|
+
)}
|
|
106
|
+
</ul>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section className="mt-8">
|
|
113
|
+
<h2 className="text-lg font-semibold tracking-tight">Assistant</h2>
|
|
114
|
+
<p className="mt-1 text-sm text-zinc-600">Answers come from the Vibiz AI gateway.</p>
|
|
115
|
+
<div className="mt-4">
|
|
116
|
+
{missingAiEnv().length ? <NotConfigured what="The AI assistant" /> : <ChatBox />}
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
</Shell>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Shell({ children, signedIn = false }: { children: React.ReactNode; signedIn?: boolean }) {
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<Header>{signedIn ? <SignOutButton /> : null}</Header>
|
|
127
|
+
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-12">{children}</main>
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* --brand and --brand-foreground come from app.config.ts (set on <html>). */
|
|
4
|
+
@theme inline {
|
|
5
|
+
--color-brand: var(--brand);
|
|
6
|
+
--color-brand-foreground: var(--brand-foreground);
|
|
7
|
+
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
8
|
+
sans-serif;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
@apply bg-white text-zinc-900 antialiased;
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
3
|
+
import { site } from '@/app.config';
|
|
4
|
+
import { Analytics } from '@/components/analytics';
|
|
5
|
+
import { ReplayConsent } from '@/components/replay-consent';
|
|
6
|
+
import './globals.css';
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: { default: site.name, template: `%s · ${site.name}` },
|
|
10
|
+
description: site.tagline,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const brand = {
|
|
14
|
+
'--brand': site.colors.brand,
|
|
15
|
+
'--brand-foreground': site.colors.brandForeground,
|
|
16
|
+
} as CSSProperties;
|
|
17
|
+
|
|
18
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
19
|
+
return (
|
|
20
|
+
<html lang="en" style={brand}>
|
|
21
|
+
<body className="flex min-h-screen flex-col font-sans">
|
|
22
|
+
{children}
|
|
23
|
+
<Analytics />
|
|
24
|
+
<ReplayConsent />
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ButtonLink } from '@/components/ui';
|
|
2
|
+
|
|
3
|
+
export default function NotFound() {
|
|
4
|
+
return (
|
|
5
|
+
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
|
|
6
|
+
<p className="text-sm font-medium text-brand">404</p>
|
|
7
|
+
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
|
8
|
+
<ButtonLink href="/" variant="secondary">
|
|
9
|
+
Go home
|
|
10
|
+
</ButtonLink>
|
|
11
|
+
</main>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { site } from '@/app.config';
|
|
2
|
+
import { Header } from '@/components/header';
|
|
3
|
+
import { ReplayChoicesLink } from '@/components/replay-consent';
|
|
4
|
+
import { ButtonLink } from '@/components/ui';
|
|
5
|
+
|
|
6
|
+
const features = [
|
|
7
|
+
{ title: 'Sign in your way', body: 'Email and password, or one click with Google.' },
|
|
8
|
+
{ title: 'Your data, saved', body: 'Everything you add is stored safely and there next time.' },
|
|
9
|
+
{ title: 'Fast everywhere', body: 'Pages load in a blink, on any device.' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export default function Home() {
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
<Header>
|
|
16
|
+
<ButtonLink href="/sign-in" variant="ghost">
|
|
17
|
+
Sign in
|
|
18
|
+
</ButtonLink>
|
|
19
|
+
<ButtonLink href="/sign-up">Get started</ButtonLink>
|
|
20
|
+
</Header>
|
|
21
|
+
|
|
22
|
+
<main className="flex-1">
|
|
23
|
+
<section className="mx-auto max-w-5xl px-6 py-24 text-center sm:py-32">
|
|
24
|
+
<h1 className="mx-auto max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-6xl">
|
|
25
|
+
{site.tagline}
|
|
26
|
+
</h1>
|
|
27
|
+
<p className="mx-auto mt-6 max-w-xl text-lg text-zinc-600 text-pretty">
|
|
28
|
+
{site.description}
|
|
29
|
+
</p>
|
|
30
|
+
<div className="mt-10 flex justify-center gap-3">
|
|
31
|
+
<ButtonLink href="/sign-up" className="h-11 px-6">
|
|
32
|
+
Create your account
|
|
33
|
+
</ButtonLink>
|
|
34
|
+
<ButtonLink href="/sign-in" variant="secondary" className="h-11 px-6">
|
|
35
|
+
Sign in
|
|
36
|
+
</ButtonLink>
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
<section className="border-t border-zinc-200 bg-zinc-50">
|
|
41
|
+
<div className="mx-auto grid max-w-5xl gap-6 px-6 py-16 sm:grid-cols-3">
|
|
42
|
+
{features.map((f) => (
|
|
43
|
+
<div key={f.title} className="rounded-xl border border-zinc-200 bg-white p-6">
|
|
44
|
+
<div className="mb-4 size-2 rounded-full bg-brand" />
|
|
45
|
+
<h2 className="font-medium">{f.title}</h2>
|
|
46
|
+
<p className="mt-2 text-sm text-zinc-600">{f.body}</p>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
</main>
|
|
52
|
+
|
|
53
|
+
<footer className="border-t border-zinc-200">
|
|
54
|
+
<div className="mx-auto flex max-w-5xl justify-between gap-4 px-6 py-8 text-sm text-zinc-500">
|
|
55
|
+
<span>
|
|
56
|
+
© {new Date().getFullYear()} {site.name}
|
|
57
|
+
</span>
|
|
58
|
+
<ReplayChoicesLink />
|
|
59
|
+
</div>
|
|
60
|
+
</footer>
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebrand the whole app here: the name, the one-line pitch and the colors.
|
|
3
|
+
* `vibiz init <name>` sets `name` for you.
|
|
4
|
+
*/
|
|
5
|
+
export const site = {
|
|
6
|
+
name: 'Acme',
|
|
7
|
+
tagline: 'The simplest way to run your business online.',
|
|
8
|
+
description: 'Sign in, keep your notes, and get on with your day.',
|
|
9
|
+
colors: {
|
|
10
|
+
/** Buttons, links and accents. */
|
|
11
|
+
brand: '#4f46e5',
|
|
12
|
+
/** Text on top of the brand color. */
|
|
13
|
+
brandForeground: '#ffffff',
|
|
14
|
+
},
|
|
15
|
+
} as const;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Script from 'next/script';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site analytics. Loads only when both values are set at build time, so a
|
|
5
|
+
* fresh app ships no script at all.
|
|
6
|
+
*/
|
|
7
|
+
export function Analytics() {
|
|
8
|
+
const key = process.env.NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY?.trim();
|
|
9
|
+
const url = process.env.NEXT_PUBLIC_VIBIZ_ANALYTICS_URL?.trim().replace(/\/$/, '');
|
|
10
|
+
if (!key || !url) return null;
|
|
11
|
+
// crossOrigin: served with CORS, so the page's own errors that pass through it keep their message.
|
|
12
|
+
return (
|
|
13
|
+
<Script
|
|
14
|
+
src={`${url}/v1/script.js`}
|
|
15
|
+
data-key={key}
|
|
16
|
+
crossOrigin="anonymous"
|
|
17
|
+
strategy="afterInteractive"
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|