@intelligo-dev/cli 1.0.0-beta.14 → 1.0.0-beta.15
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 +24 -0
- package/dist/args.d.ts +10 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +20 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.js +69 -25
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +19 -2
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +4 -0
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +39 -4
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +16 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +42 -7
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +102 -16
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +34 -3
- package/dist/commands/sync.js.map +1 -1
- package/dist/env-files.d.ts +11 -0
- package/dist/env-files.d.ts.map +1 -1
- package/dist/env-files.js +28 -9
- package/dist/env-files.js.map +1 -1
- package/package.json +1 -1
- package/src/args.ts +25 -0
- package/src/bin.ts +78 -28
- package/src/commands/add.ts +28 -12
- package/src/commands/create-flow.ts +38 -4
- package/src/commands/create.ts +65 -7
- package/src/commands/doctor.ts +129 -20
- package/src/commands/sync.ts +42 -2
- package/src/env-files.ts +28 -7
- package/templates/admin-page/admin-page.tsx.tpl +96 -24
- package/templates/app-scaffold/gitignore.tpl +25 -0
- package/templates/app-scaffold/next.config.mjs.tpl +31 -2
- package/templates/app-scaffold/package.json.tpl +2 -2
- package/templates/manifest.json +21 -3
- package/templates/pnpm-standalone/npmrc.tpl +6 -0
- package/templates/pnpm-standalone/pnpm-workspace.yaml.tpl +10 -0
- package/templates/registry/app-shell.json +2 -2
- package/templates/registry/billing-settings.json +8 -2
- package/templates/registry/chat.json +1 -1
- package/templates/registry/payment-poll.json +11 -5
- package/templates/registry/pricing.json +8 -2
- package/templates/registry/registry.json +18 -3
- package/templates/registry/trial-banner.json +2 -2
- package/templates/registry-items.json +3 -3
- package/templates/registry-requires.json +7 -5
package/src/commands/sync.ts
CHANGED
|
@@ -207,7 +207,16 @@ export function syncCheck(
|
|
|
207
207
|
): SyncReport {
|
|
208
208
|
const { closure, files } = shippedFiles(context, items);
|
|
209
209
|
const seams = context.requires.seams ?? {};
|
|
210
|
-
const
|
|
210
|
+
const manifest = readManifest(context.appRoot);
|
|
211
|
+
const recorded = manifest?.registry?.files ?? {};
|
|
212
|
+
// What the scaffold wrote, by hash: a file it wrote and nobody edited
|
|
213
|
+
// is the registry's to replace, not someone's work.
|
|
214
|
+
const scaffolded = new Map(
|
|
215
|
+
(manifest?.features[SCAFFOLD_FEATURE]?.files ?? []).map((f) => [
|
|
216
|
+
f.path,
|
|
217
|
+
f.hash,
|
|
218
|
+
])
|
|
219
|
+
);
|
|
211
220
|
|
|
212
221
|
const entries: SyncEntry[] = files.map(({ item, target, content }) => {
|
|
213
222
|
const abs = path.join(context.appRoot, target);
|
|
@@ -229,7 +238,16 @@ export function syncCheck(
|
|
|
229
238
|
return { item, path: target, state: "current" };
|
|
230
239
|
}
|
|
231
240
|
const hash = recorded[target];
|
|
232
|
-
if (hash === undefined)
|
|
241
|
+
if (hash === undefined) {
|
|
242
|
+
return {
|
|
243
|
+
item,
|
|
244
|
+
path: target,
|
|
245
|
+
state:
|
|
246
|
+
scaffolded.get(target) === hashContents(local)
|
|
247
|
+
? "outdated"
|
|
248
|
+
: "differs",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
233
251
|
return {
|
|
234
252
|
item,
|
|
235
253
|
path: target,
|
|
@@ -395,6 +413,28 @@ function recordSync(
|
|
|
395
413
|
});
|
|
396
414
|
}
|
|
397
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Record `names` as items this app keeps in sync before any is
|
|
418
|
+
* installed, so a bare `intelligo sync` installs them after an install
|
|
419
|
+
* that did not run or did not finish.
|
|
420
|
+
*/
|
|
421
|
+
export function recordItems(
|
|
422
|
+
names: readonly string[],
|
|
423
|
+
context: Pick<SyncContext, "appRoot" | "requires" | "frameworkVersion">
|
|
424
|
+
): void {
|
|
425
|
+
const manifest =
|
|
426
|
+
readManifest(context.appRoot) ?? emptyManifest(context.frameworkVersion);
|
|
427
|
+
const items = new Set([...(manifest.registry?.items ?? []), ...names]);
|
|
428
|
+
writeManifest(context.appRoot, {
|
|
429
|
+
...manifest,
|
|
430
|
+
registry: {
|
|
431
|
+
version: manifest.registry?.version ?? context.frameworkVersion,
|
|
432
|
+
items: installOrder([...items], context.requires),
|
|
433
|
+
files: manifest.registry?.files ?? {},
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
398
438
|
export type SyncApplyOptions = {
|
|
399
439
|
force?: boolean;
|
|
400
440
|
log?: (line: string) => void;
|
package/src/env-files.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseEnv } from "node:util";
|
|
4
4
|
|
|
5
|
+
import { findWorkspaceRoot } from "./registry-items.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* The env files a Next.js app reads, in the order it reads them: an
|
|
7
9
|
* earlier file wins, and a variable already set in the shell wins over
|
|
@@ -13,19 +15,38 @@ import { parseEnv } from "node:util";
|
|
|
13
15
|
*/
|
|
14
16
|
export const ENV_FILES = [".env.local", ".env"] as const;
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Loads the app's env files into `env`, then — when the app is a member
|
|
20
|
+
* of a pnpm workspace — the workspace root's, for an app whose
|
|
21
|
+
* `next.config` falls back to the repository root's `.env`. Precedence,
|
|
22
|
+
* highest first: the shell, the app's `.env.local`, the app's `.env`,
|
|
23
|
+
* the root's `.env.local`, the root's `.env`. A variable already set is
|
|
24
|
+
* never overridden.
|
|
25
|
+
*
|
|
26
|
+
* Returns the files it read, relative to `root` (`.env.local`,
|
|
27
|
+
* `../../.env`).
|
|
28
|
+
*/
|
|
16
29
|
export function loadAppEnv(
|
|
17
30
|
root: string,
|
|
18
31
|
env: NodeJS.ProcessEnv = process.env
|
|
19
32
|
): string[] {
|
|
33
|
+
const dirs = [root];
|
|
34
|
+
const workspaceRoot = findWorkspaceRoot(root);
|
|
35
|
+
if (workspaceRoot && path.resolve(workspaceRoot) !== path.resolve(root)) {
|
|
36
|
+
dirs.push(workspaceRoot);
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
const loaded: string[] = [];
|
|
21
|
-
for (const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
for (const dir of dirs) {
|
|
41
|
+
for (const name of ENV_FILES) {
|
|
42
|
+
const file = path.join(dir, name);
|
|
43
|
+
if (!existsSync(file)) continue;
|
|
44
|
+
const values = parseEnv(readFileSync(file, "utf8"));
|
|
45
|
+
for (const [key, value] of Object.entries(values)) {
|
|
46
|
+
if (env[key] === undefined) env[key] = value;
|
|
47
|
+
}
|
|
48
|
+
loaded.push(path.relative(root, file));
|
|
27
49
|
}
|
|
28
|
-
loaded.push(name);
|
|
29
50
|
}
|
|
30
51
|
return loaded;
|
|
31
52
|
}
|
|
@@ -1,55 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The operational console, mounted in your app.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The screens ship from @intelligo-dev/admin so every deployment shows
|
|
5
|
+
* the same operational truth — a fork could quietly stop showing you
|
|
6
|
+
* unsettled executions. What is generated here is the mount: the route,
|
|
7
|
+
* the authorization call and the page around the screens. The page is
|
|
8
|
+
* yours: add your product's own sections after the package's, with the
|
|
9
|
+
* data your own queries return.
|
|
8
10
|
*
|
|
9
|
-
* Access is
|
|
10
|
-
*
|
|
11
|
+
* Access is a platform-admin row (`users.role`), seeded from
|
|
12
|
+
* PLATFORM_ADMIN_EMAILS, not a workspace role: workspace `owner` is
|
|
13
|
+
* per-tenant and every signup has one. A caller who is not one gets the
|
|
14
|
+
* app's 404, because "Not authorized" — or a redirect — on a route says
|
|
15
|
+
* the route exists.
|
|
16
|
+
*
|
|
17
|
+
* Each screen reads on its own: one that fails says so, and the rest of
|
|
18
|
+
* the console still renders.
|
|
11
19
|
*/
|
|
12
20
|
|
|
21
|
+
import { notFound } from "next/navigation";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
|
|
13
24
|
import {
|
|
25
|
+
getIntegrationHealth,
|
|
14
26
|
getPlatformOverview,
|
|
27
|
+
listFailedJobs,
|
|
15
28
|
listUnsettledExecutions,
|
|
16
29
|
listWorkspaces,
|
|
30
|
+
queryAuditEvents,
|
|
17
31
|
requireAdmin,
|
|
18
32
|
} from "@intelligo-dev/admin";
|
|
19
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
IntegrationHealthView,
|
|
35
|
+
OperationsView,
|
|
36
|
+
PlatformOverviewView,
|
|
37
|
+
} from "@intelligo-dev/admin/views";
|
|
20
38
|
|
|
21
39
|
import { composeIntelligo } from "@/lib/intelligo";
|
|
22
40
|
|
|
23
41
|
export const dynamic = "force-dynamic";
|
|
24
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The package's screens are unstyled markup; these rules give their
|
|
45
|
+
* headings, lists and tables the app's type and tokens.
|
|
46
|
+
*/
|
|
47
|
+
const SCREEN =
|
|
48
|
+
"space-y-4 rounded-xl border border-border bg-card p-6 text-sm text-card-foreground " +
|
|
49
|
+
"[&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold [&_section]:space-y-2 " +
|
|
50
|
+
"[&_ul]:list-disc [&_ul]:pl-5 [&_table]:w-full [&_th]:py-1 [&_th]:text-left " +
|
|
51
|
+
"[&_th]:font-medium [&_th]:text-muted-foreground [&_td]:border-t [&_td]:border-border [&_td]:py-1";
|
|
52
|
+
|
|
53
|
+
/** A screen whose read failed, in place of the screen. */
|
|
54
|
+
function Unavailable({ what }: { what: string }) {
|
|
55
|
+
return (
|
|
56
|
+
<p className="text-muted-foreground">
|
|
57
|
+
{what} could not be read — see the server log.
|
|
58
|
+
</p>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function screen<T>(
|
|
63
|
+
result: PromiseSettledResult<T>,
|
|
64
|
+
render: (value: T) => ReactNode,
|
|
65
|
+
what: string
|
|
66
|
+
) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={SCREEN}>
|
|
69
|
+
{result.status === "fulfilled" ? (
|
|
70
|
+
render(result.value)
|
|
71
|
+
) : (
|
|
72
|
+
<Unavailable what={what} />
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
export default async function AdminPage() {
|
|
26
79
|
composeIntelligo();
|
|
27
80
|
|
|
28
81
|
try {
|
|
29
82
|
await requireAdmin("admin.overview.viewed");
|
|
30
83
|
} catch {
|
|
31
|
-
|
|
32
|
-
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
|
|
33
|
-
<h1>Admin</h1>
|
|
34
|
-
<p>Not authorized.</p>
|
|
35
|
-
</main>
|
|
36
|
-
);
|
|
84
|
+
notFound();
|
|
37
85
|
}
|
|
38
86
|
|
|
39
|
-
const [overview,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
87
|
+
const [overview, integrations, operations] = await Promise.allSettled([
|
|
88
|
+
Promise.all([
|
|
89
|
+
getPlatformOverview(),
|
|
90
|
+
listWorkspaces(20),
|
|
91
|
+
listUnsettledExecutions(),
|
|
92
|
+
]),
|
|
93
|
+
getIntegrationHealth(),
|
|
94
|
+
Promise.all([listFailedJobs(20), queryAuditEvents({ limit: 20 })]),
|
|
43
95
|
]);
|
|
44
96
|
|
|
45
97
|
return (
|
|
46
|
-
<main
|
|
47
|
-
<h1>Admin</h1>
|
|
48
|
-
|
|
49
|
-
overview
|
|
50
|
-
workspaces
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
<main className="mx-auto max-w-5xl space-y-6 bg-background px-4 py-8 text-foreground">
|
|
99
|
+
<h1 className="text-2xl font-semibold">Admin</h1>
|
|
100
|
+
{screen(
|
|
101
|
+
overview,
|
|
102
|
+
([platform, workspaces, unsettled]) => (
|
|
103
|
+
<PlatformOverviewView
|
|
104
|
+
overview={platform}
|
|
105
|
+
workspaces={workspaces}
|
|
106
|
+
unsettled={unsettled}
|
|
107
|
+
/>
|
|
108
|
+
),
|
|
109
|
+
"The platform overview"
|
|
110
|
+
)}
|
|
111
|
+
{screen(
|
|
112
|
+
integrations,
|
|
113
|
+
(value) => (
|
|
114
|
+
<IntegrationHealthView integrations={value} />
|
|
115
|
+
),
|
|
116
|
+
"Integration health"
|
|
117
|
+
)}
|
|
118
|
+
{screen(
|
|
119
|
+
operations,
|
|
120
|
+
([failedJobs, auditEvents]) => (
|
|
121
|
+
<OperationsView failedJobs={failedJobs} auditEvents={auditEvents} />
|
|
122
|
+
),
|
|
123
|
+
"Operations"
|
|
124
|
+
)}
|
|
53
125
|
</main>
|
|
54
126
|
);
|
|
55
127
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# next.js
|
|
5
|
+
.next/
|
|
6
|
+
out/
|
|
7
|
+
next-env.d.ts
|
|
8
|
+
|
|
9
|
+
# production
|
|
10
|
+
build/
|
|
11
|
+
|
|
12
|
+
# env files: .env.example is the only one committed
|
|
13
|
+
.env
|
|
14
|
+
.env*.local
|
|
15
|
+
|
|
16
|
+
# debug
|
|
17
|
+
npm-debug.log*
|
|
18
|
+
pnpm-debug.log*
|
|
19
|
+
|
|
20
|
+
# misc
|
|
21
|
+
.DS_Store
|
|
22
|
+
*.pem
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
.vercel
|
|
25
|
+
coverage/
|
|
@@ -1,11 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
5
|
+
|
|
1
6
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
2
7
|
|
|
8
|
+
// Next reads env files from this directory only. When `intelligo create`
|
|
9
|
+
// made this app as a member of a pnpm workspace, the workspace root's
|
|
10
|
+
// .env.local and .env fill in what neither they nor the shell set — the
|
|
11
|
+
// files `intelligo doctor` and `intelligo migrate` read there too. Null
|
|
12
|
+
// for an app outside any workspace.
|
|
13
|
+
const WORKSPACE_ROOT = __WORKSPACE_ROOT__;
|
|
14
|
+
|
|
15
|
+
if (WORKSPACE_ROOT) {
|
|
16
|
+
const root = join(fileURLToPath(new URL(".", import.meta.url)), WORKSPACE_ROOT);
|
|
17
|
+
for (const file of [".env.local", ".env"]) {
|
|
18
|
+
let content;
|
|
19
|
+
try {
|
|
20
|
+
content = readFileSync(join(root, file), "utf8");
|
|
21
|
+
} catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (const [key, value] of Object.entries(parseEnv(content))) {
|
|
25
|
+
if (process.env[key] === undefined) process.env[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|
4
31
|
|
|
5
32
|
/** @type {import('next').NextConfig} */
|
|
6
33
|
const nextConfig = {
|
|
7
|
-
//
|
|
8
|
-
//
|
|
34
|
+
// Inside the framework's own workspace these packages resolve to
|
|
35
|
+
// TypeScript source; from npm they are compiled JavaScript, which this
|
|
36
|
+
// leaves as it is.
|
|
9
37
|
transpilePackages: [
|
|
10
38
|
"@intelligo-dev/admin",
|
|
11
39
|
"@intelligo-dev/audit",
|
|
@@ -14,6 +42,7 @@ const nextConfig = {
|
|
|
14
42
|
"@intelligo-dev/chat",
|
|
15
43
|
"@intelligo-dev/core",
|
|
16
44
|
"@intelligo-dev/executions",
|
|
45
|
+
"@intelligo-dev/jobs",
|
|
17
46
|
"@intelligo-dev/next",
|
|
18
47
|
],
|
|
19
48
|
};
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "next dev --turbopack
|
|
7
|
+
"dev": "next dev --turbopack",
|
|
8
8
|
"build": "next build",
|
|
9
|
-
"start": "next start
|
|
9
|
+
"start": "next start",
|
|
10
10
|
"type-check": "tsc --noEmit",
|
|
11
11
|
"db:generate": "drizzle-kit generate",
|
|
12
12
|
"db:migrate": "intelligo migrate && drizzle-kit migrate",
|
package/templates/manifest.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"deprecated": "Superseded by the `usage` registry item \u2014 install it with the shadcn CLI from the Intelligo registry instead; it ships a full page, components and thin actions as consumer-owned source."
|
|
12
12
|
},
|
|
13
13
|
"app-scaffold": {
|
|
14
|
-
"templateVersion": "1.
|
|
14
|
+
"templateVersion": "1.15.0",
|
|
15
15
|
"description": "A new application: composition root (run once per server process from instrumentation.ts), plans, an assistant route, a Stripe webhook endpoint, config, a drizzle config for the tables you own, next-intl i18n plumbing, and a registry-consuming shadcn base-nova setup with the intelligo design tokens",
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
|
@@ -125,12 +125,30 @@
|
|
|
125
125
|
{
|
|
126
126
|
"template": "app-scaffold/stripe-webhook-route.ts.tpl",
|
|
127
127
|
"target": "app/api/webhooks/stripe/route.ts"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"template": "app-scaffold/gitignore.tpl",
|
|
131
|
+
"target": ".gitignore"
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"pnpm-standalone": {
|
|
136
|
+
"templateVersion": "1.0.0",
|
|
137
|
+
"description": "pnpm settings for an app that is its own workspace root: dependency build scripts declined, so pnpm 10+ installs without a prompt, and `pnpm add` allowed at the root for the shadcn CLI. `intelligo create` writes it when pnpm installs an app outside any workspace",
|
|
138
|
+
"files": [
|
|
139
|
+
{
|
|
140
|
+
"template": "pnpm-standalone/pnpm-workspace.yaml.tpl",
|
|
141
|
+
"target": "pnpm-workspace.yaml"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"template": "pnpm-standalone/npmrc.tpl",
|
|
145
|
+
"target": ".npmrc"
|
|
128
146
|
}
|
|
129
147
|
]
|
|
130
148
|
},
|
|
131
149
|
"admin-page": {
|
|
132
|
-
"templateVersion": "1.
|
|
133
|
-
"description": "Mount the Intelligo operational console at /admin",
|
|
150
|
+
"templateVersion": "1.2.0",
|
|
151
|
+
"description": "Mount the Intelligo operational console at /admin, styled with the app's tokens, for platform admins only",
|
|
134
152
|
"files": [
|
|
135
153
|
{
|
|
136
154
|
"template": "admin-page/admin-page.tsx.tpl",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# pnpm 9 reads this setting here; later versions read it from
|
|
2
|
+
# pnpm-workspace.yaml. pnpm-workspace.yaml makes this directory a
|
|
3
|
+
# workspace root, and pnpm refuses a plain `pnpm add` in one unless told
|
|
4
|
+
# it is intended — the shadcn CLI runs exactly that when it installs a
|
|
5
|
+
# registry page.
|
|
6
|
+
ignore-workspace-root-check=true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# This app is its own pnpm workspace root. pnpm 11 stops the install over
|
|
2
|
+
# a dependency build script nobody approved (10 warns); these ship
|
|
3
|
+
# prebuilt binaries, so their scripts are declined.
|
|
4
|
+
packages: []
|
|
5
|
+
allowBuilds:
|
|
6
|
+
'@parcel/watcher': false
|
|
7
|
+
'@swc/core': false
|
|
8
|
+
esbuild: false
|
|
9
|
+
# The shadcn CLI runs a plain `pnpm add` here when it installs a page.
|
|
10
|
+
ignoreWorkspaceRootCheck: true
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
{
|
|
26
26
|
"path": "base/app-shell/layout.tsx",
|
|
27
|
-
"content": "import { getLocale, getTranslations } from \"next-intl/server\";\n/**\n * The layout for every route under `(app)`. Checks, in order:\n *\n * 1. A session, checked here because middleware only reads the cookie.\n * None → `/login`.\n * 2. Completed onboarding. Incomplete → `/onboarding
|
|
27
|
+
"content": "import { getLocale, getTranslations } from \"next-intl/server\";\n/**\n * The layout for every route under `(app)`. Checks, in order:\n *\n * 1. A session, checked here because middleware only reads the cookie.\n * None → `/login`.\n * 2. Completed onboarding. Incomplete → `shellConfig.onboardingRedirect`\n * (`/onboarding` unless the seam says otherwise; `false` skips the\n * check). The onboarding route is a sibling of `(app)`, so this\n * cannot redirect into itself; if you nest onboarding under `(app)`,\n * skip this check on that route.\n * 3. An active workspace. `ensureUserWorkspace` creates one if none\n * exists; what a new workspace starts with is the handler\n * `lib/intelligo.ts` sets with `setWorkspaceCreatedHandler`.\n */\n\nimport { eq } from \"drizzle-orm\";\nimport { headers } from \"next/headers\";\nimport type { ReactNode } from \"react\";\n\nimport {\n auth,\n ensureUserWorkspace,\n getAuthSession,\n getWorkspaceContextById,\n} from \"@intelligo-dev/auth\";\nimport { db } from \"@intelligo-dev/core/db\";\nimport { users } from \"@intelligo-dev/core/db/schema\";\n\nimport { AppSidebar } from \"@/components/shell/app-sidebar\";\nimport { PageTransition } from \"@/components/shell/page-transition\";\nimport { ShellHeader } from \"@/components/shell/shell-header\";\nimport { TimeZoneCookie } from \"@/components/shell/time-zone-cookie\";\nimport { AISidebarInset, AISidebarProvider } from \"@/components/ui/ai-sidebar\";\nimport { redirect } from \"@/i18n/navigation\";\nimport { shellConfig } from \"@/lib/shell-config\";\n\nconst SKIP_LINK_CLASS =\n \"sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-ring focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-3 focus:ring-ring/40\";\n\nexport default async function AppLayout({ children }: { children: ReactNode }) {\n const session = await getAuthSession();\n if (!session) {\n redirect({ href: \"/login\", locale: await getLocale() });\n return null;\n }\n\n // Read with `in`: a seam written before the field existed declares a\n // ShellConfig without it, and still compiles.\n const onboarding =\n \"onboardingRedirect\" in shellConfig\n ? shellConfig.onboardingRedirect\n : undefined;\n if (onboarding !== false) {\n const [userRecord] = await db\n .select({ onboardingCompleted: users.onboardingCompleted })\n .from(users)\n .where(eq(users.id, session.user.id))\n .limit(1);\n\n if (userRecord && !userRecord.onboardingCompleted) {\n redirect({\n href: typeof onboarding === \"string\" ? onboarding : \"/onboarding\",\n locale: await getLocale(),\n });\n return null;\n }\n }\n\n const hdrs = await headers();\n const activeWorkspaceId = await ensureUserWorkspace(session.user, hdrs);\n\n const [workspaces, workspaceContext] = await Promise.all([\n auth.api.listOrganizations({ headers: hdrs }).then((orgs) => orgs ?? []),\n getWorkspaceContextById(activeWorkspaceId),\n ]);\n\n if (!workspaceContext) {\n redirect({ href: \"/login\", locale: await getLocale() });\n return null;\n }\n\n const workspaceList = workspaces.map((org) => ({\n id: org.id,\n name: org.name,\n slug: org.slug,\n logo: org.logo ?? null,\n }));\n\n // The shell is exactly one screen tall and the page region scrolls\n // inside it. A shell that grows with its page scrolls the window\n // instead, and a page that pins something to the bottom — the chat\n // composer — or scrolls its own region — the transcript — has no\n // height to work in.\n const SidebarContent = shellConfig.sidebarContent;\n const t = await getTranslations(\"app-shell\");\n return (\n <AISidebarProvider className=\"h-svh min-h-0 overflow-hidden\">\n {/* The first focusable element: keyboard users jump past the sidebar. */}\n <a href=\"#main-content\" className={SKIP_LINK_CLASS}>\n {t(\"skipToContent\")}\n </a>\n {/* Renders nothing; tells the server which day it is here. */}\n <TimeZoneCookie />\n <AppSidebar\n workspace={workspaceContext.workspace}\n workspaces={workspaceList}\n user={{\n name: session.user.name,\n email: session.user.email,\n image: session.user.image,\n }}\n >\n {SidebarContent ? <SidebarContent /> : null}\n </AppSidebar>\n <AISidebarInset\n id=\"main-content\"\n tabIndex={-1}\n className=\"min-h-0 overflow-hidden outline-none\"\n >\n <ShellHeader>\n {shellConfig.headerRight && (\n <div className=\"ml-auto flex items-center gap-2\">\n <shellConfig.headerRight />\n </div>\n )}\n </ShellHeader>\n {shellConfig.bannerTop && <shellConfig.bannerTop />}\n <PageTransition className=\"min-h-0 flex-1 overflow-y-auto\">\n {children}\n </PageTransition>\n </AISidebarInset>\n </AISidebarProvider>\n );\n}\n",
|
|
28
28
|
"type": "registry:page",
|
|
29
29
|
"target": "app/[locale]/(app)/layout.tsx"
|
|
30
30
|
},
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
{
|
|
74
74
|
"path": "base/app-shell/lib/shell-config.tsx",
|
|
75
|
-
"content": "/**\n * What your product adds around the app shell, without editing\n * `layout.tsx` or `components/shell/*`. Every slot is optional and takes\n * a component with no props; one that needs data fetches it itself.\n *\n * - `bannerTop`: above the page content on every authenticated page\n * (a trial banner, an incident notice).\n * - `headerRight`: the right end of the header (a notification bell,\n * a language switcher).\n * - `sidebarContent`: the sidebar under the navigation (conversation\n * history). May be an async server component; it refreshes with the\n * page. Hiding itself when the sidebar collapses is its own call.\n *\n * For example:\n *\n * import { TrialBanner } from \"@/components/trial/trial-banner\";\n *\n * export const shellConfig: ShellConfig = {\n * bannerTop: TrialBanner,\n * };\n */\n\nimport type { ComponentType } from \"react\";\n\nexport interface ShellConfig {\n /** Rendered inside `SidebarInset`, above `main`. Takes no props. */\n bannerTop?: ComponentType;\n /**\n * Rendered at the right end of the shell header — e.g. a\n * notification bell, a language switcher, or both. Takes no props.\n */\n headerRight?: ComponentType;\n /**\n * Rendered in the sidebar under the navigation — e.g. conversation\n * history. Takes no props; may be an async server component.\n */\n sidebarContent?: ComponentType;\n}\n\nexport const shellConfig: ShellConfig = {};\n",
|
|
75
|
+
"content": "/**\n * What your product adds around the app shell, without editing\n * `layout.tsx` or `components/shell/*`. Every slot is optional and takes\n * a component with no props; one that needs data fetches it itself.\n *\n * - `bannerTop`: above the page content on every authenticated page\n * (a trial banner, an incident notice).\n * - `headerRight`: the right end of the header (a notification bell,\n * a language switcher).\n * - `sidebarContent`: the sidebar under the navigation (conversation\n * history). May be an async server component; it refreshes with the\n * page. Hiding itself when the sidebar collapses is its own call.\n * - `onboardingRedirect`: where a signed-in user who has not finished\n * onboarding is sent (default `/onboarding`), or `false` for a\n * product without an onboarding step. Point it at a route outside\n * `(app)`: one this layout wraps would redirect to itself forever.\n *\n * For example:\n *\n * import { TrialBanner } from \"@/components/trial/trial-banner\";\n *\n * export const shellConfig: ShellConfig = {\n * bannerTop: TrialBanner,\n * };\n */\n\nimport type { ComponentType } from \"react\";\n\nexport interface ShellConfig {\n /** Rendered inside `SidebarInset`, above `main`. Takes no props. */\n bannerTop?: ComponentType;\n /**\n * Rendered at the right end of the shell header — e.g. a\n * notification bell, a language switcher, or both. Takes no props.\n */\n headerRight?: ComponentType;\n /**\n * Rendered in the sidebar under the navigation — e.g. conversation\n * history. Takes no props; may be an async server component.\n */\n sidebarContent?: ComponentType;\n /**\n * Where a user who has not completed onboarding is sent before any\n * page under `(app)` renders. `false` sends nobody: the product has\n * no onboarding, or completes it elsewhere. Default `/onboarding`.\n */\n onboardingRedirect?: string | false;\n}\n\nexport const shellConfig: ShellConfig = {};\n",
|
|
76
76
|
"type": "registry:file",
|
|
77
77
|
"target": "lib/shell-config.tsx"
|
|
78
78
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "billing-settings",
|
|
4
4
|
"title": "Billing Settings",
|
|
5
|
-
"description": "Workspace billing management: current plan, credit balance, credit bundle purchase, and the Stripe billing portal, shaped server-side by role (member/admin/owner). Requires the pricing item (provides lib/billing.ts and actions/billing.ts); install it first.",
|
|
5
|
+
"description": "Workspace billing management: current plan, credit balance, credit bundle purchase, and the Stripe billing portal, shaped server-side by role (member/admin/owner). Requires the pricing item (provides lib/billing.ts and actions/billing.ts); install it first. Other ways to buy a credit bundle render under its button from the lib/credit-bundle-config.tsx seam, which can also hide the card button (the payment-poll item's LocalPaymentButton binds there).",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"@intelligo-dev/auth",
|
|
8
8
|
"@intelligo-dev/billing",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"path": "base/billing-settings/components/credit-bundles.tsx",
|
|
42
|
-
"content": "\"use client\";\n\n/**\n * This deployment's credit bundles (`CREDIT_BUNDLES` from `@/lib/billing`,\n * installed by the `pricing` item) for one-time purchase.\n *\n * `bundle.name` is deployment config (`lib/billing-config.ts`), not this\n * item's copy, so it renders verbatim; localize it in that file.\n *\n * Each of a bundle's two amounts is formatted in its own currency:\n * `price` is what the buyer pays the payment provider, `grant` is what\n * lands in the ledger. A pack can cost $5 and grant ₮100,000.\n *\n * A bundle in the legacy `{ credits, priceUsd }` shape is read as whole\n * units of `CURRENCY`, priced in dollars.\n */\n\nimport { useState } from \"react\";\nimport { Coins } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\n\nimport { createCreditPurchaseSession } from \"@/actions/billing\";\nimport { CREDIT_BUNDLES, CURRENCY } from \"@/lib/billing-config\";\nimport { formatMoney } from \"@/lib/format-money\";\n\n/** Micros are millionths of one major unit. */\nconst MICROS_PER_UNIT = 1_000_000;\n\ntype Bundle = (typeof CREDIT_BUNDLES)[number];\ntype Amount = { amount: number; currency: string };\n\n/** What the workspace receives, in the ledger's own currency. */\nfunction grantOf(bundle: Bundle): Amount {\n return \"grant\" in bundle\n ? bundle.grant\n : { amount: bundle.credits * MICROS_PER_UNIT, currency: CURRENCY };\n}\n\n/** What the buyer pays, in the currency the provider charges. */\nfunction priceOf(bundle: Bundle): Amount {\n return \"price\" in bundle\n ? bundle.price\n : {\n amount: Math.round(bundle.priceUsd * MICROS_PER_UNIT),\n currency: \"USD\",\n };\n}\n\ninterface CreditBundlesProps {\n /** The top-up balance, in the ledger's own currency. */\n currentBalance?: Amount | null;\n}\n\nexport function CreditBundles({ currentBalance }: CreditBundlesProps) {\n const t = useTranslations(\"billing-settings\");\n const format = useFormatter();\n const [loadingId, setLoadingId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n const handlePurchase = async (bundleId: string) => {\n setError(null);\n setLoadingId(bundleId);\n\n const result = await createCreditPurchaseSession({ bundleId });\n\n if (!result.success) {\n setError(result.error);\n setLoadingId(null);\n return;\n }\n\n window.location.href = result.data.url;\n };\n\n return (\n <div className=\"space-y-4\">\n <div>\n <p className=\"text-sm font-medium\">{t(\"creditBundles.buyMore\")}</p>\n {currentBalance && (\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.currentBalance\", {\n balance: formatMoney(format, currentBalance),\n })}\n </p>\n )}\n </div>\n\n <AnimatedList as=\"div\" className=\"grid grid-cols-1 gap-4 md:grid-cols-3\">\n {CREDIT_BUNDLES.map((bundle) => {\n const isLoading = loadingId === bundle.id;\n const grant = grantOf(bundle);\n const price = priceOf(bundle);\n return (\n <AnimatedListItem as=\"div\" key={bundle.id}>\n <Card className=\"h-full space-y-4 p-6\">\n <div className=\"flex items-center gap-2\">\n <Coins className=\"size-5 text-muted-foreground\" />\n <p className=\"font-medium\">{bundle.name}</p>\n </div>\n <p className=\"text-2xl font-semibold text-foreground\">\n {formatMoney(format, grant)}\n </p>\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.oneTime\", {\n price: formatMoney(format, price),\n })}\n </p>\n <Button\n
|
|
42
|
+
"content": "\"use client\";\n\n/**\n * This deployment's credit bundles (`CREDIT_BUNDLES` from `@/lib/billing`,\n * installed by the `pricing` item) for one-time purchase.\n *\n * `bundle.name` is deployment config (`lib/billing-config.ts`), not this\n * item's copy, so it renders verbatim; localize it in that file.\n *\n * Each of a bundle's two amounts is formatted in its own currency:\n * `price` is what the buyer pays the payment provider, `grant` is what\n * lands in the ledger. A pack can cost $5 and grant ₮100,000.\n *\n * A bundle in the legacy `{ credits, priceUsd }` shape is read as whole\n * units of `CURRENCY`, priced in dollars.\n *\n * Other ways to buy a bundle render under its button from\n * `lib/credit-bundle-config.tsx`, which can also hide the card button.\n */\n\nimport { useState } from \"react\";\nimport { Coins } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\n\nimport { createCreditPurchaseSession } from \"@/actions/billing\";\nimport { CREDIT_BUNDLES, CURRENCY } from \"@/lib/billing-config\";\nimport { creditBundleConfig } from \"@/lib/credit-bundle-config\";\nimport { formatMoney } from \"@/lib/format-money\";\n\n/** Micros are millionths of one major unit. */\nconst MICROS_PER_UNIT = 1_000_000;\n\ntype Bundle = (typeof CREDIT_BUNDLES)[number];\ntype Amount = { amount: number; currency: string };\n\n/** What the workspace receives, in the ledger's own currency. */\nfunction grantOf(bundle: Bundle): Amount {\n return \"grant\" in bundle\n ? bundle.grant\n : { amount: bundle.credits * MICROS_PER_UNIT, currency: CURRENCY };\n}\n\n/** What the buyer pays, in the currency the provider charges. */\nfunction priceOf(bundle: Bundle): Amount {\n return \"price\" in bundle\n ? bundle.price\n : {\n amount: Math.round(bundle.priceUsd * MICROS_PER_UNIT),\n currency: \"USD\",\n };\n}\n\ninterface CreditBundlesProps {\n /** The top-up balance, in the ledger's own currency. */\n currentBalance?: Amount | null;\n}\n\nexport function CreditBundles({ currentBalance }: CreditBundlesProps) {\n const t = useTranslations(\"billing-settings\");\n const format = useFormatter();\n const [loadingId, setLoadingId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n const Actions = creditBundleConfig.actions;\n const cardCheckout = creditBundleConfig.cardCheckout !== false;\n\n const handlePurchase = async (bundleId: string) => {\n setError(null);\n setLoadingId(bundleId);\n\n const result = await createCreditPurchaseSession({ bundleId });\n\n if (!result.success) {\n setError(result.error);\n setLoadingId(null);\n return;\n }\n\n window.location.href = result.data.url;\n };\n\n return (\n <div className=\"space-y-4\">\n <div>\n <p className=\"text-sm font-medium\">{t(\"creditBundles.buyMore\")}</p>\n {currentBalance && (\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.currentBalance\", {\n balance: formatMoney(format, currentBalance),\n })}\n </p>\n )}\n </div>\n\n <AnimatedList as=\"div\" className=\"grid grid-cols-1 gap-4 md:grid-cols-3\">\n {CREDIT_BUNDLES.map((bundle) => {\n const isLoading = loadingId === bundle.id;\n const grant = grantOf(bundle);\n const price = priceOf(bundle);\n return (\n <AnimatedListItem as=\"div\" key={bundle.id}>\n <Card className=\"h-full space-y-4 p-6\">\n <div className=\"flex items-center gap-2\">\n <Coins className=\"size-5 text-muted-foreground\" />\n <p className=\"font-medium\">{bundle.name}</p>\n </div>\n <p className=\"text-2xl font-semibold text-foreground\">\n {formatMoney(format, grant)}\n </p>\n <p className=\"text-sm text-muted-foreground\">\n {t(\"creditBundles.oneTime\", {\n price: formatMoney(format, price),\n })}\n </p>\n {cardCheckout && (\n <Button\n onClick={() => handlePurchase(bundle.id)}\n disabled={isLoading}\n variant=\"outline\"\n className=\"w-full\"\n >\n {isLoading\n ? t(\"creditBundles.redirecting\")\n : t(\"creditBundles.purchase\")}\n </Button>\n )}\n {Actions && \"grant\" in bundle && (\n <Actions\n bundle={bundle}\n price={price.amount / MICROS_PER_UNIT}\n currency={price.currency}\n bundleName={bundle.name}\n />\n )}\n </Card>\n </AnimatedListItem>\n );\n })}\n </AnimatedList>\n\n {error && (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n )}\n </div>\n );\n}\n",
|
|
43
43
|
"type": "registry:component",
|
|
44
44
|
"target": "components/billing/credit-bundles.tsx"
|
|
45
45
|
},
|
|
@@ -54,6 +54,12 @@
|
|
|
54
54
|
"content": "\"use client\";\n\n/**\n * Error boundary for billing settings. Renders the shared `RouteError` from the\n * `route-error` item — install it alongside this one.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return <RouteError error={error} reset={reset} scope=\"settings-billing\" />;\n}\n",
|
|
55
55
|
"type": "registry:page",
|
|
56
56
|
"target": "app/[locale]/(app)/settings/billing/error.tsx"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "base/billing-settings/lib/credit-bundle-config.tsx",
|
|
60
|
+
"content": "/**\n * How your product sells its credit bundles, without editing\n * `components/billing/credit-bundles.tsx`.\n *\n * - `actions`: rendered under a bundle's purchase button, as another\n * way to buy it (a QR payment rail, a bank transfer). It receives the\n * bundle, its price in major units with the price's currency, and the\n * bundle's name. Bundles in the legacy `{ credits, priceUsd }` shape\n * are sold by card only and get no actions.\n * - `cardCheckout`: `false` hides the card (Stripe) purchase button, for\n * a deployment that sells bundles only another way. With no `actions`\n * as well, nothing can buy a bundle.\n *\n * Empty by default: bundles are bought by card alone. To sell them\n * through the QR-and-poll rail, install the `payment-poll` item, price\n * each bundle in its `lib/local-payment.ts`, and bind its button here.\n * A bundle's `price` is what the card processor charges; the QR provider\n * charges in `CURRENCY`, so offer the button only for a bundle priced in\n * it, and give its reference a prefix plans do not use:\n *\n * import { LocalPaymentButton } from \"@/components/billing/local-payment-button\";\n * import { CURRENCY } from \"@/lib/billing-config\";\n *\n * export const creditBundleConfig: CreditBundleConfig = {\n * actions: ({ bundle, price, currency, bundleName }) =>\n * currency === CURRENCY ? (\n * <LocalPaymentButton\n * reference={`credits:${bundle.id}`}\n * amount={price}\n * label={bundleName}\n * />\n * ) : null,\n * };\n *\n * The amount is only displayed; `priceLocalPayment(reference)` prices\n * the invoice on the server.\n */\n\nimport type { ComponentType } from \"react\";\n\nimport type { CreditBundle } from \"@intelligo-dev/billing\";\n\nexport interface CreditBundleActionProps {\n bundle: CreditBundle;\n /** What the buyer pays, in major units of `currency`. */\n price: number;\n currency: string;\n /** The bundle's name as the card renders it. */\n bundleName: string;\n}\n\nexport interface CreditBundleConfig {\n /** Rendered under each bundle's purchase button. */\n actions?: ComponentType<CreditBundleActionProps>;\n /** `false` hides the card purchase button. Default `true`. */\n cardCheckout?: boolean;\n}\n\nexport const creditBundleConfig: CreditBundleConfig = {};\n",
|
|
61
|
+
"type": "registry:file",
|
|
62
|
+
"target": "lib/credit-bundle-config.tsx"
|
|
57
63
|
}
|
|
58
64
|
],
|
|
59
65
|
"type": "registry:block"
|
|
@@ -296,7 +296,7 @@
|
|
|
296
296
|
},
|
|
297
297
|
{
|
|
298
298
|
"path": "base/chat/components/chat-history-nav.tsx",
|
|
299
|
-
"content": "\"use client\";\n\n/**\n * Conversation history in the app's own sidebar, under the navigation —\n * grouped by recency with pinned on top, filterable, and every row with\n * rename, pin and delete (with a few seconds to change your mind). The\n * row for the conversation on screen is highlighted from the URL, and\n * the group hides when the sidebar collapses to icons. A deleted row\n * folds away and comes back on undo; a new one slides in.\n *\n * Keyboard: F2 renames the focused row, Delete deletes it.\n *\n * Filtering is client-side over what the server sent (the newest 100).\n * Past that size it belongs in a query.\n */\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n MoreHorizontalIcon,\n PencilIcon,\n PinIcon,\n PinOffIcon,\n PlusIcon,\n Trash2Icon,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { Link, usePathname, useRouter } from \"@/i18n/navigation\";\nimport { listItem } from \"@/components/ui/ai-motion\";\nimport {\n AISidebarItem,\n AISidebarMenu,\n AISidebarMenuItem,\n AISidebarSection,\n useAISidebar,\n useAISidebarPanel,\n} from \"@/components/ui/ai-sidebar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n deleteConversation,\n renameConversation,\n setConversationPinned,\n type ConversationSummary,\n} from \"@/actions/chat\";\n\ntype Bucket = \"pinned\" | \"today\" | \"yesterday\" | \"week\" | \"month\" | \"older\";\n\nconst BUCKET_ORDER: Bucket[] = [\n \"pinned\",\n \"today\",\n \"yesterday\",\n \"week\",\n \"month\",\n \"older\",\n];\n\nconst UNDO_MS = 5000;\n/** The search field shows once the list is long enough to need it. */\nconst SEARCH_FROM = 8;\n\nfunction bucketFor(conversation: ConversationSummary, now: number): Bucket {\n if (conversation.pinned) return \"pinned\";\n const ageMs = now - new Date(conversation.updatedAt).getTime();\n const day = 24 * 60 * 60 * 1000;\n if (ageMs < day) return \"today\";\n if (ageMs < 2 * day) return \"yesterday\";\n if (ageMs < 7 * day) return \"week\";\n if (ageMs < 30 * day) return \"month\";\n return \"older\";\n}\n\nexport function ChatHistoryNav({\n conversations,\n}: {\n conversations: ConversationSummary[];\n}) {\n const t = useTranslations(\"chat\");\n const router = useRouter();\n const pathname = usePathname();\n const { isMobile, setOpenMobile } = useAISidebar();\n const { collapsed } = useAISidebarPanel();\n const reduced = useReducedMotion() ?? false;\n const activeId = pathname?.match(/^\\/chat\\/([^/]+)/)?.[1] ?? null;\n\n const [query, setQuery] = useState(\"\");\n const [renamingId, setRenamingId] = useState<string | null>(null);\n const [draft, setDraft] = useState(\"\");\n const [hidden, setHidden] = useState<Set<string>>(new Set());\n const [titles, setTitles] = useState<Record<string, string>>({});\n const [pins, setPins] = useState<Record<string, boolean>>({});\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n // Read when a delete lands, seconds after the click: by then the\n // reader may have opened another conversation.\n const activeIdRef = useRef(activeId);\n activeIdRef.current = activeId;\n\n // Leaving with a delete still in its undo window keeps the delete:\n // the reader asked for it and did not take it back.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n for (const [id, timer] of pending) {\n clearTimeout(timer);\n void deleteConversation(id);\n }\n pending.clear();\n };\n }, []);\n\n const rows = useMemo(\n () =>\n conversations\n .filter((conversation) => !hidden.has(conversation.id))\n .map((conversation) => ({\n ...conversation,\n title: titles[conversation.id] ?? conversation.title,\n pinned: pins[conversation.id] ?? conversation.pinned,\n })),\n [conversations, hidden, titles, pins]\n );\n\n const grouped = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const now = Date.now();\n const buckets = new Map<Bucket, typeof rows>();\n for (const conversation of rows) {\n if (\n needle &&\n conversation.id !== activeId &&\n !(conversation.title ?? \"\").toLowerCase().includes(needle)\n ) {\n continue;\n }\n const bucket = bucketFor(conversation, now);\n buckets.set(bucket, [...(buckets.get(bucket) ?? []), conversation]);\n }\n return buckets;\n }, [rows, query, activeId]);\n\n const empty = BUCKET_ORDER.every((bucket) => !grouped.get(bucket)?.length);\n\n function closeOnMobile() {\n if (isMobile) setOpenMobile(false);\n }\n\n function startRename(conversation: ConversationSummary) {\n setRenamingId(conversation.id);\n setDraft(titles[conversation.id] ?? conversation.title ?? \"\");\n }\n\n function commitRename(id: string) {\n const trimmed = draft.trim();\n setRenamingId(null);\n if (!trimmed) return;\n setTitles((previous) => ({ ...previous, [id]: trimmed }));\n void renameConversation(id, trimmed).then((result) => {\n if (!result.success) {\n setTitles((previous) => {\n const next = { ...previous };\n delete next[id];\n return next;\n });\n toast.error(result.error);\n return;\n }\n router.refresh();\n });\n }\n\n function togglePin(conversation: ConversationSummary) {\n const next = !(pins[conversation.id] ?? conversation.pinned);\n setPins((previous) => ({ ...previous, [conversation.id]: next }));\n void setConversationPinned(conversation.id, next).then((result) => {\n if (!result.success) {\n setPins((previous) => ({ ...previous, [conversation.id]: !next }));\n toast.error(result.error);\n }\n });\n }\n\n function remove(id: string) {\n setHidden((previous) => new Set(previous).add(id));\n const timer = setTimeout(() => {\n timers.current.delete(id);\n void deleteConversation(id).then((result) => {\n if (!result.success) {\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n toast.error(result.error);\n return;\n }\n if (id === activeIdRef.current) router.push(\"/chat\");\n router.refresh();\n });\n }, UNDO_MS);\n timers.current.set(id, timer);\n toast(t(\"sidebar.deleted\"), {\n duration: UNDO_MS,\n action: {\n label: t(\"sidebar.undo\"),\n onClick: () => {\n const pendingTimer = timers.current.get(id);\n if (pendingTimer) clearTimeout(pendingTimer);\n timers.current.delete(id);\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n },\n },\n });\n }\n\n // In the icon rail there is no room for titles; the nav rows stay.\n if (collapsed) return null;\n\n return (\n <AISidebarSection\n label={t(\"sidebar.label\")}\n action={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.newChat\")}\n title={t(\"sidebar.newChat\")}\n render={<Link href=\"/chat\" onClick={closeOnMobile} />}\n >\n <PlusIcon />\n </Button>\n }\n >\n <div className=\"flex flex-col gap-1\">\n {rows.length >= SEARCH_FROM ? (\n <Input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={t(\"sidebar.searchPlaceholder\")}\n aria-label={t(\"sidebar.searchPlaceholder\")}\n className=\"mb-1 h-8 bg-background\"\n />\n ) : null}\n\n {empty ? (\n <p className=\"px-2 py-1 text-xs text-muted-foreground\">\n {query ? t(\"sidebar.noMatches\") : t(\"header.historyEmpty\")}\n </p>\n ) : (\n BUCKET_ORDER.map((bucket) => {\n const items = grouped.get(bucket);\n if (!items?.length) return null;\n return (\n <div key={bucket} className=\"flex flex-col\">\n <p className=\"px-2 pt-2 pb-1 text-xs text-muted-foreground\">\n {t(`sidebar.groups.${bucket}`)}\n </p>\n <AISidebarMenu>\n <AnimatePresence initial={false}>\n {items.map((conversation) => {\n const label =\n conversation.title ?? t(\"header.historyUntitled\");\n const motionProps = reduced\n ? {}\n : {\n variants: listItem,\n initial: \"hidden\",\n animate: \"shown\",\n exit: \"exit\",\n };\n if (renamingId === conversation.id) {\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <Input\n autoFocus\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onBlur={() => commitRename(conversation.id)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitRename(conversation.id);\n }\n if (event.key === \"Escape\") setRenamingId(null);\n }}\n aria-label={t(\"sidebar.rename\")}\n className=\"h-8 bg-background\"\n />\n </AISidebarMenuItem>\n );\n }\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <AISidebarItem\n isActive={conversation.id === activeId}\n title={label}\n render={<Link href={`/chat/${conversation.id}`} />}\n onKeyDown={(event) => {\n if (event.key === \"F2\") {\n event.preventDefault();\n startRename(conversation);\n } else if (event.key === \"Delete\") {\n event.preventDefault();\n remove(conversation.id);\n }\n }}\n action={\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.menu\")}\n />\n }\n >\n <MoreHorizontalIcon />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"start\">\n <DropdownMenuItem\n onClick={() => togglePin(conversation)}\n >\n {conversation.pinned ? (\n <PinOffIcon />\n ) : (\n <PinIcon />\n )}\n {conversation.pinned\n ? t(\"sidebar.unpin\")\n : t(\"sidebar.pin\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => startRename(conversation)}\n >\n <PencilIcon />\n {t(\"sidebar.rename\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n variant=\"destructive\"\n onClick={() => remove(conversation.id)}\n >\n <Trash2Icon />\n {t(\"sidebar.delete\")}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n }\n >\n {label}\n </AISidebarItem>\n </AISidebarMenuItem>\n );\n })}\n </AnimatePresence>\n </AISidebarMenu>\n </div>\n );\n })\n )}\n </div>\n </AISidebarSection>\n );\n}\n",
|
|
299
|
+
"content": "\"use client\";\n\n/**\n * Conversation history in the app's own sidebar, under the navigation —\n * grouped by recency with pinned on top, filterable, and every row with\n * rename, pin and delete (with a few seconds to change your mind). The\n * row for the conversation on screen is highlighted from the URL, and\n * the group hides when the sidebar collapses to icons. A deleted row\n * folds away and comes back on undo; a new one slides in.\n *\n * Keyboard: F2 renames the focused row, Delete deletes it.\n *\n * Filtering is client-side over what the server sent (the newest 100).\n * Past that size it belongs in a query.\n */\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n MoreHorizontalIcon,\n PencilIcon,\n PinIcon,\n PinOffIcon,\n PlusIcon,\n Trash2Icon,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { Link, usePathname, useRouter } from \"@/i18n/navigation\";\nimport { listItem } from \"@/components/ui/ai-motion\";\nimport {\n AISidebarItem,\n AISidebarMenu,\n AISidebarMenuItem,\n AISidebarSection,\n useAISidebar,\n useAISidebarPanel,\n} from \"@/components/ui/ai-sidebar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n deleteConversation,\n renameConversation,\n setConversationPinned,\n type ConversationSummary,\n} from \"@/actions/chat\";\n\ntype Bucket = \"pinned\" | \"today\" | \"yesterday\" | \"week\" | \"month\" | \"older\";\n\nconst BUCKET_ORDER: Bucket[] = [\n \"pinned\",\n \"today\",\n \"yesterday\",\n \"week\",\n \"month\",\n \"older\",\n];\n\nconst UNDO_MS = 5000;\n/** The search field shows once the list is long enough to need it. */\nconst SEARCH_FROM = 8;\n\nfunction bucketFor(conversation: ConversationSummary, now: number): Bucket {\n if (conversation.pinned) return \"pinned\";\n const ageMs = now - new Date(conversation.updatedAt).getTime();\n const day = 24 * 60 * 60 * 1000;\n if (ageMs < day) return \"today\";\n if (ageMs < 2 * day) return \"yesterday\";\n if (ageMs < 7 * day) return \"week\";\n if (ageMs < 30 * day) return \"month\";\n return \"older\";\n}\n\nexport function ChatHistoryNav({\n conversations,\n}: {\n conversations: ConversationSummary[];\n}) {\n const t = useTranslations(\"chat\");\n const router = useRouter();\n const pathname = usePathname();\n const { isMobile, setOpenMobile } = useAISidebar();\n const { collapsed } = useAISidebarPanel();\n const reduced = useReducedMotion() ?? false;\n const activeId = pathname?.match(/^\\/chat\\/([^/]+)/)?.[1] ?? null;\n\n const [query, setQuery] = useState(\"\");\n const [renamingId, setRenamingId] = useState<string | null>(null);\n const [draft, setDraft] = useState(\"\");\n const [hidden, setHidden] = useState<Set<string>>(new Set());\n const [titles, setTitles] = useState<Record<string, string>>({});\n const [pins, setPins] = useState<Record<string, boolean>>({});\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n // Read when a delete lands, seconds after the click: by then the\n // reader may have opened another conversation.\n const activeIdRef = useRef(activeId);\n activeIdRef.current = activeId;\n\n // Leaving with a delete still in its undo window keeps the delete:\n // the reader asked for it and did not take it back.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n for (const [id, timer] of pending) {\n clearTimeout(timer);\n void deleteConversation(id);\n }\n pending.clear();\n };\n }, []);\n\n const rows = useMemo(\n () =>\n conversations\n .filter((conversation) => !hidden.has(conversation.id))\n .map((conversation) => ({\n ...conversation,\n title: titles[conversation.id] ?? conversation.title,\n pinned: pins[conversation.id] ?? conversation.pinned,\n })),\n [conversations, hidden, titles, pins]\n );\n\n const grouped = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const now = Date.now();\n const buckets = new Map<Bucket, typeof rows>();\n for (const conversation of rows) {\n if (\n needle &&\n conversation.id !== activeId &&\n !(conversation.title ?? \"\").toLowerCase().includes(needle)\n ) {\n continue;\n }\n const bucket = bucketFor(conversation, now);\n buckets.set(bucket, [...(buckets.get(bucket) ?? []), conversation]);\n }\n return buckets;\n }, [rows, query, activeId]);\n\n const empty = BUCKET_ORDER.every((bucket) => !grouped.get(bucket)?.length);\n\n function closeOnMobile() {\n if (isMobile) setOpenMobile(false);\n }\n\n function startRename(conversation: ConversationSummary) {\n setRenamingId(conversation.id);\n setDraft(titles[conversation.id] ?? conversation.title ?? \"\");\n }\n\n function commitRename(id: string) {\n const trimmed = draft.trim();\n setRenamingId(null);\n if (!trimmed) return;\n setTitles((previous) => ({ ...previous, [id]: trimmed }));\n void renameConversation(id, trimmed).then((result) => {\n if (!result.success) {\n setTitles((previous) => {\n const next = { ...previous };\n delete next[id];\n return next;\n });\n toast.error(result.error);\n return;\n }\n router.refresh();\n });\n }\n\n function togglePin(conversation: ConversationSummary) {\n const next = !(pins[conversation.id] ?? conversation.pinned);\n setPins((previous) => ({ ...previous, [conversation.id]: next }));\n void setConversationPinned(conversation.id, next).then((result) => {\n if (!result.success) {\n setPins((previous) => ({ ...previous, [conversation.id]: !next }));\n toast.error(result.error);\n }\n });\n }\n\n function remove(id: string) {\n setHidden((previous) => new Set(previous).add(id));\n const timer = setTimeout(() => {\n timers.current.delete(id);\n void deleteConversation(id).then((result) => {\n if (!result.success) {\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n toast.error(result.error);\n return;\n }\n if (id === activeIdRef.current) router.push(\"/chat\");\n router.refresh();\n });\n }, UNDO_MS);\n timers.current.set(id, timer);\n toast(t(\"sidebar.deleted\"), {\n duration: UNDO_MS,\n action: {\n label: t(\"sidebar.undo\"),\n onClick: () => {\n const pendingTimer = timers.current.get(id);\n if (pendingTimer) clearTimeout(pendingTimer);\n timers.current.delete(id);\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n },\n },\n });\n }\n\n // In the icon rail there is no room for titles; the nav rows stay.\n if (collapsed) return null;\n\n return (\n <AISidebarSection\n label={t(\"sidebar.label\")}\n action={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.newChat\")}\n title={t(\"sidebar.newChat\")}\n render={<Link href=\"/chat\" onClick={closeOnMobile} />}\n nativeButton={false}\n >\n <PlusIcon />\n </Button>\n }\n >\n <div className=\"flex flex-col gap-1\">\n {rows.length >= SEARCH_FROM ? (\n <Input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={t(\"sidebar.searchPlaceholder\")}\n aria-label={t(\"sidebar.searchPlaceholder\")}\n className=\"mb-1 h-8 bg-background\"\n />\n ) : null}\n\n {empty ? (\n <p className=\"px-2 py-1 text-xs text-muted-foreground\">\n {query ? t(\"sidebar.noMatches\") : t(\"header.historyEmpty\")}\n </p>\n ) : (\n BUCKET_ORDER.map((bucket) => {\n const items = grouped.get(bucket);\n if (!items?.length) return null;\n return (\n <div key={bucket} className=\"flex flex-col\">\n <p className=\"px-2 pt-2 pb-1 text-xs text-muted-foreground\">\n {t(`sidebar.groups.${bucket}`)}\n </p>\n <AISidebarMenu>\n <AnimatePresence initial={false}>\n {items.map((conversation) => {\n const label =\n conversation.title ?? t(\"header.historyUntitled\");\n const motionProps = reduced\n ? {}\n : {\n variants: listItem,\n initial: \"hidden\",\n animate: \"shown\",\n exit: \"exit\",\n };\n if (renamingId === conversation.id) {\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <Input\n autoFocus\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onBlur={() => commitRename(conversation.id)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitRename(conversation.id);\n }\n if (event.key === \"Escape\") setRenamingId(null);\n }}\n aria-label={t(\"sidebar.rename\")}\n className=\"h-8 bg-background\"\n />\n </AISidebarMenuItem>\n );\n }\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <AISidebarItem\n isActive={conversation.id === activeId}\n title={label}\n render={<Link href={`/chat/${conversation.id}`} />}\n onKeyDown={(event) => {\n if (event.key === \"F2\") {\n event.preventDefault();\n startRename(conversation);\n } else if (event.key === \"Delete\") {\n event.preventDefault();\n remove(conversation.id);\n }\n }}\n action={\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.menu\")}\n />\n }\n >\n <MoreHorizontalIcon />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"start\">\n <DropdownMenuItem\n onClick={() => togglePin(conversation)}\n >\n {conversation.pinned ? (\n <PinOffIcon />\n ) : (\n <PinIcon />\n )}\n {conversation.pinned\n ? t(\"sidebar.unpin\")\n : t(\"sidebar.pin\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => startRename(conversation)}\n >\n <PencilIcon />\n {t(\"sidebar.rename\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n variant=\"destructive\"\n onClick={() => remove(conversation.id)}\n >\n <Trash2Icon />\n {t(\"sidebar.delete\")}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n }\n >\n {label}\n </AISidebarItem>\n </AISidebarMenuItem>\n );\n })}\n </AnimatePresence>\n </AISidebarMenu>\n </div>\n );\n })\n )}\n </div>\n </AISidebarSection>\n );\n}\n",
|
|
300
300
|
"type": "registry:component",
|
|
301
301
|
"target": "components/chat/chat-history-nav.tsx"
|
|
302
302
|
},
|