@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.14
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 +41 -2
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +9 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +4 -1
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +63 -31
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +8 -4
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +15 -7
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -19
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +20 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +35 -6
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -8
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +103 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +361 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +4 -1
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +30 -3
- package/dist/manifest.js.map +1 -1
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +41 -13
- package/dist/registry-items.d.ts.map +1 -1
- package/dist/registry-items.js +117 -33
- package/dist/registry-items.js.map +1 -1
- package/package.json +1 -1
- package/src/bin.ts +52 -0
- package/src/commands/add.ts +11 -0
- package/src/commands/create-flow.ts +82 -26
- package/src/commands/create.ts +17 -7
- package/src/commands/doctor.ts +27 -27
- package/src/commands/migrate-check.ts +51 -10
- package/src/commands/migrate.ts +2 -8
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +534 -0
- package/src/commands/upgrade-check.ts +4 -1
- package/src/manifest.ts +57 -3
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +141 -36
- package/templates/app-scaffold/intelligo.ts.tpl +6 -47
- package/templates/manifest.json +19 -0
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +60 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +52 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +111 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2945 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +1 -1
- package/templates/registry-requires.json +26 -2
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "app-shell",
|
|
4
|
+
"title": "App Shell",
|
|
5
|
+
"description": "Authenticated application shell: session and onboarding gate, workspace bootstrap, a sidebar that morphs into an icon rail with a gliding active pill, workspace switcher, configurable nav and user menu, and a page region that lifts each section in.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"@intelligo-dev/core",
|
|
9
|
+
"next-themes",
|
|
10
|
+
"drizzle-orm",
|
|
11
|
+
"lucide-react",
|
|
12
|
+
"sonner",
|
|
13
|
+
"motion",
|
|
14
|
+
"server-only",
|
|
15
|
+
"next-intl"
|
|
16
|
+
],
|
|
17
|
+
"registryDependencies": [
|
|
18
|
+
"avatar",
|
|
19
|
+
"@intelligo/dropdown-menu",
|
|
20
|
+
"separator",
|
|
21
|
+
"@intelligo/ai-sidebar",
|
|
22
|
+
"@intelligo/ai-motion"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
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`. The onboarding\n * route is a sibling of `(app)`, so this cannot redirect into itself;\n * if you nest onboarding under `(app)`, 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 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({ href: \"/onboarding\", locale: await getLocale() });\n return null;\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
|
+
"type": "registry:page",
|
|
29
|
+
"target": "app/[locale]/(app)/layout.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "base/app-shell/components/app-sidebar.tsx",
|
|
33
|
+
"content": "\"use client\";\n\n/**\n * The shell's sidebar. `items` replaces the nav from `@/lib/nav-config`\n * entirely. It collapses to an icon rail on desktop (⌘/Ctrl-B, or the\n * header's trigger) and slides in as a sheet on mobile.\n */\n\nimport * as React from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport type { LucideIcon } from \"lucide-react\";\n\nimport { Link, usePathname } from \"@/i18n/navigation\";\nimport {\n AISidebar,\n AISidebarContent,\n AISidebarFooter,\n AISidebarHeader,\n AISidebarItem,\n AISidebarMenu,\n AISidebarMenuItem,\n AISidebarRail,\n AISidebarSection,\n} from \"@/components/ui/ai-sidebar\";\nimport { UserMenu } from \"./user-menu\";\nimport { WorkspaceSwitcher, type Workspace } from \"./workspace-switcher\";\nimport { navItems as configuredNavItems } from \"@/lib/nav-config\";\n\nexport interface NavItem {\n /**\n * A message key in the `app-shell` namespace (e.g.\n * `\"sidebar.nav.dashboard\"`), not a literal title.\n */\n titleKey: string;\n href: string;\n icon?: LucideIcon;\n}\n\ninterface AppSidebarProps {\n workspace: Workspace | null;\n workspaces: Workspace[];\n user: {\n name: string | null;\n email: string;\n image?: string | null;\n };\n items?: NavItem[];\n /** Rendered under the navigation — `shellConfig.sidebarContent`. */\n children?: React.ReactNode;\n}\n\nexport function AppSidebar({\n workspace,\n workspaces,\n user,\n items = configuredNavItems,\n children,\n}: AppSidebarProps) {\n const pathname = usePathname();\n const t = useTranslations(\"app-shell\");\n\n return (\n <AISidebar\n collapsible=\"icon\"\n label={t(\"sidebar.label\")}\n closeLabel={t(\"sidebar.close\")}\n >\n <AISidebarHeader>\n <WorkspaceSwitcher\n currentWorkspace={workspace}\n workspaces={workspaces}\n />\n </AISidebarHeader>\n\n <AISidebarContent>\n <AISidebarSection>\n <AISidebarMenu>\n {items.map((item) => {\n const isActive =\n pathname === item.href || pathname?.startsWith(`${item.href}/`);\n return (\n <AISidebarMenuItem key={item.href}>\n <AISidebarItem\n isActive={isActive}\n icon={item.icon ? <item.icon /> : undefined}\n tooltip={t(item.titleKey)}\n render={<Link href={item.href} />}\n >\n {t(item.titleKey)}\n </AISidebarItem>\n </AISidebarMenuItem>\n );\n })}\n </AISidebarMenu>\n </AISidebarSection>\n {children}\n </AISidebarContent>\n\n <AISidebarFooter>\n <UserMenu user={user} />\n </AISidebarFooter>\n <AISidebarRail label={t(\"sidebar.toggle\")} />\n </AISidebar>\n );\n}\n",
|
|
34
|
+
"type": "registry:component",
|
|
35
|
+
"target": "components/shell/app-sidebar.tsx"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "base/app-shell/components/workspace-switcher.tsx",
|
|
39
|
+
"content": "\"use client\";\n\n/**\n * Lists the caller's workspaces (fetched by the layout) and activates one\n * through Better-Auth's organization plugin. It has no create-workspace\n * action; build one on `authClient.organization.create` if you need it.\n */\n\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { useTranslations } from \"next-intl\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useAISidebar, useAISidebarPanel } from \"@/components/ui/ai-sidebar\";\nimport { cn } from \"@/lib/utils\";\nimport { useRouter } from \"@/i18n/navigation\";\n\nconst TRIGGER =\n \"flex w-full min-w-0 items-center gap-2 rounded-xl p-1.5 text-left text-sidebar-foreground outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:opacity-50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground\";\n\nexport interface Workspace {\n id: string;\n name: string;\n slug: string;\n logo?: string | null;\n}\n\ninterface WorkspaceSwitcherProps {\n currentWorkspace: Workspace | null;\n workspaces: Workspace[];\n}\n\nexport function WorkspaceSwitcher({\n currentWorkspace,\n workspaces,\n}: WorkspaceSwitcherProps) {\n const { isMobile } = useAISidebar();\n const { collapsed } = useAISidebarPanel();\n const router = useRouter();\n const [isSwitching, setIsSwitching] = React.useState(false);\n const t = useTranslations(\"app-shell\");\n\n if (!currentWorkspace) return null;\n\n async function handleSwitch(workspaceId: string) {\n if (!currentWorkspace || workspaceId === currentWorkspace.id) return;\n setIsSwitching(true);\n try {\n // Better-Auth answers a refusal as `{ error }` rather than throwing.\n const { error } = await authClient.organization.setActive({\n organizationId: workspaceId,\n });\n if (error) {\n toast.error(t(\"workspaceSwitcher.switchFailed\"));\n return;\n }\n router.refresh();\n } catch {\n toast.error(t(\"workspaceSwitcher.switchFailed\"));\n } finally {\n setIsSwitching(false);\n }\n }\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <button\n type=\"button\"\n disabled={isSwitching}\n className={cn(TRIGGER, collapsed && \"justify-center\")}\n />\n }\n >\n <div className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-xs font-semibold text-sidebar-primary-foreground\">\n {currentWorkspace.name[0]?.toUpperCase()}\n </div>\n {collapsed ? null : (\n <>\n <div className=\"grid min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-medium\">\n {currentWorkspace.name}\n </span>\n <span className=\"truncate text-xs text-muted-foreground\">\n {currentWorkspace.slug}\n </span>\n </div>\n {workspaces.length > 1 && (\n <ChevronsUpDown className=\"ml-auto size-4 opacity-50\" />\n )}\n </>\n )}\n </DropdownMenuTrigger>\n {workspaces.length > 1 && (\n <DropdownMenuContent\n className=\"w-(--anchor-width) min-w-56\"\n align=\"start\"\n side={isMobile ? \"bottom\" : \"right\"}\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n {t(\"workspaceSwitcher.workspacesLabel\")}\n </DropdownMenuLabel>\n <DropdownMenuSeparator />\n {workspaces.map((ws) => (\n <DropdownMenuItem\n key={ws.id}\n onClick={() => handleSwitch(ws.id)}\n disabled={isSwitching}\n className=\"gap-2\"\n >\n <div className=\"flex size-6 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-xs font-semibold text-sidebar-primary-foreground\">\n {ws.name[0]?.toUpperCase()}\n </div>\n <div className=\"grid flex-1 leading-tight\">\n <span className=\"truncate text-sm\">{ws.name}</span>\n <span className=\"truncate text-xs text-muted-foreground\">\n {ws.slug}\n </span>\n </div>\n {ws.id === currentWorkspace.id && (\n <Check className=\"ml-auto size-4\" />\n )}\n </DropdownMenuItem>\n ))}\n </DropdownMenuGroup>\n </DropdownMenuContent>\n )}\n </DropdownMenu>\n );\n}\n",
|
|
40
|
+
"type": "registry:component",
|
|
41
|
+
"target": "components/shell/workspace-switcher.tsx"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "base/app-shell/components/user-menu.tsx",
|
|
45
|
+
"content": "\"use client\";\n\n/**\n * Avatar dropdown: account pages (`accountItems` from `@/lib/nav-config`),\n * settings, a theme submenu and sign out.\n *\n * The theme submenu only switches themes once a `ThemeProvider` is\n * mounted; without one `useTheme()` is a no-op. The `mounted` check\n * avoids a hydration mismatch between the server render (no theme) and\n * the client's resolved theme.\n */\n\nimport * as React from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n ChevronsUpDown,\n LogOut,\n Monitor,\n Moon,\n Settings,\n Sun,\n User,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Link, useRouter } from \"@/i18n/navigation\";\nimport { accountItems } from \"@/lib/nav-config\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useAISidebar, useAISidebarPanel } from \"@/components/ui/ai-sidebar\";\nimport { cn } from \"@/lib/utils\";\n\nconst TRIGGER =\n \"flex w-full min-w-0 items-center gap-2 rounded-xl p-1.5 text-left text-sidebar-foreground outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:opacity-50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground\";\n\ninterface UserMenuProps {\n user: {\n name: string | null;\n email: string;\n image?: string | null;\n };\n}\n\nexport function UserMenu({ user }: UserMenuProps) {\n const { isMobile } = useAISidebar();\n const { collapsed } = useAISidebarPanel();\n const { theme, setTheme } = useTheme();\n const [mounted, setMounted] = React.useState(false);\n const router = useRouter();\n const t = useTranslations(\"app-shell\");\n\n React.useEffect(() => {\n setMounted(true);\n }, []);\n\n const initials = user.name\n ? user.name\n .split(\" \")\n .map((part) => part[0])\n .join(\"\")\n .toUpperCase()\n .slice(0, 2)\n : user.email[0]?.toUpperCase();\n\n async function handleSignOut() {\n await authClient.signOut();\n router.push(\"/login\");\n }\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <button\n type=\"button\"\n className={cn(TRIGGER, collapsed && \"justify-center\")}\n />\n }\n >\n <Avatar className=\"size-8 rounded-lg\">\n {user.image && <AvatarImage src={user.image} alt={user.name ?? \"\"} />}\n <AvatarFallback className=\"rounded-lg\">{initials}</AvatarFallback>\n </Avatar>\n {collapsed ? null : (\n <>\n <div className=\"grid min-w-0 flex-1 text-left leading-tight\">\n <span className=\"truncate text-sm font-medium\">\n {user.name || user.email.split(\"@\")[0]}\n </span>\n <span className=\"truncate text-xs text-muted-foreground\">\n {user.email}\n </span>\n </div>\n <ChevronsUpDown className=\"ml-auto size-4 opacity-50\" />\n </>\n )}\n </DropdownMenuTrigger>\n <DropdownMenuContent\n className=\"w-(--anchor-width) min-w-56\"\n side={isMobile ? \"bottom\" : collapsed ? \"right\" : \"top\"}\n align=\"end\"\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"p-0 font-normal\">\n <div className=\"flex items-center gap-2 px-2 py-1.5\">\n <Avatar className=\"size-8 rounded-lg\">\n {user.image && (\n <AvatarImage src={user.image} alt={user.name ?? \"\"} />\n )}\n <AvatarFallback className=\"rounded-lg\">\n {initials}\n </AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 leading-tight\">\n <span className=\"truncate text-sm font-medium\">\n {user.name || user.email.split(\"@\")[0]}\n </span>\n <span className=\"truncate text-xs text-muted-foreground\">\n {user.email}\n </span>\n </div>\n </div>\n </DropdownMenuLabel>\n </DropdownMenuGroup>\n <DropdownMenuSeparator />\n\n <DropdownMenuGroup>\n <DropdownMenuItem render={<Link href=\"/settings\" />}>\n <User />\n {t(\"userMenu.profile\")}\n </DropdownMenuItem>\n <DropdownMenuItem render={<Link href=\"/settings/workspace\" />}>\n <Settings />\n {t(\"userMenu.workspaceSettings\")}\n </DropdownMenuItem>\n </DropdownMenuGroup>\n <DropdownMenuSeparator />\n\n {accountItems.length > 0 ? (\n <>\n <DropdownMenuGroup>\n {accountItems.map((item) => {\n const Icon = item.icon;\n return (\n <DropdownMenuItem\n key={item.href}\n render={<Link href={item.href} />}\n >\n {Icon ? <Icon /> : null}\n {t(item.titleKey)}\n </DropdownMenuItem>\n );\n })}\n </DropdownMenuGroup>\n <DropdownMenuSeparator />\n </>\n ) : null}\n\n <DropdownMenuSub>\n <DropdownMenuSubTrigger>\n {mounted && theme === \"dark\" ? <Moon /> : <Sun />}\n {t(\"userMenu.theme\")}\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent>\n <DropdownMenuItem onClick={() => setTheme(\"light\")}>\n <Sun />\n {t(\"userMenu.themeLight\")}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"dark\")}>\n <Moon />\n {t(\"userMenu.themeDark\")}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"system\")}>\n <Monitor />\n {t(\"userMenu.themeSystem\")}\n </DropdownMenuItem>\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n\n <DropdownMenuSeparator />\n <DropdownMenuItem\n onClick={handleSignOut}\n className=\"text-destructive focus:text-destructive\"\n >\n <LogOut />\n {t(\"userMenu.signOut\")}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
|
|
46
|
+
"type": "registry:component",
|
|
47
|
+
"target": "components/shell/user-menu.tsx"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "base/app-shell/components/shell-header.tsx",
|
|
51
|
+
"content": "/**\n * Shell top bar. It must render `AISidebarTrigger`: on mobile the sidebar\n * is an off-canvas sheet with no other way to open it. `children` adds\n * breadcrumbs or actions. A page portals its own bar into the empty\n * `shell-header-slot` (the chat's title and actions do) rather than\n * stacking a second header under this one.\n */\n\nimport type { ReactNode } from \"react\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { AISidebarTrigger } from \"@/components/ui/ai-sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface ShellHeaderProps {\n children?: ReactNode;\n}\n\nexport async function ShellHeader({ children }: ShellHeaderProps) {\n const t = await getTranslations(\"app-shell\");\n\n return (\n <header className=\"flex h-14 shrink-0 items-center gap-2 border-b px-4\">\n <AISidebarTrigger\n size=\"icon-sm\"\n label={t(\"sidebar.toggle\")}\n className=\"-ml-1\"\n />\n <div className=\"mr-2 flex h-4 items-center\">\n <Separator orientation=\"vertical\" />\n </div>\n <div\n data-slot=\"shell-header-slot\"\n className=\"flex min-w-0 flex-1 items-center\"\n />\n {children}\n </header>\n );\n}\n",
|
|
52
|
+
"type": "registry:component",
|
|
53
|
+
"target": "components/shell/shell-header.tsx"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "base/app-shell/components/theme-provider.tsx",
|
|
57
|
+
"content": "\"use client\";\n\n/**\n * The user menu's Light/Dark/System submenu drives `next-themes`, which\n * is inert until this provider is mounted above it. The root layout is\n * yours, so mounting it is one manual step:\n *\n * // app/[locale]/layout.tsx\n * import { ThemeProvider } from \"@/components/shell/theme-provider\";\n * <html lang={locale} suppressHydrationWarning>\n * <body>\n * <ThemeProvider>{children}</ThemeProvider>\n * </body>\n * </html>\n *\n * `suppressHydrationWarning` is required: next-themes sets the class\n * attribute before hydration.\n *\n * Defaults: class strategy (Tailwind's `.dark` tokens), system preference\n * honoured, no transition on switch. Props pass through to override them.\n */\n\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\";\nimport type { ThemeProviderProps } from \"next-themes\";\n\nexport function ThemeProvider({ children, ...props }: ThemeProviderProps) {\n return (\n <NextThemesProvider\n attribute=\"class\"\n defaultTheme=\"system\"\n enableSystem\n disableTransitionOnChange\n {...props}\n >\n {children}\n </NextThemesProvider>\n );\n}\n",
|
|
58
|
+
"type": "registry:component",
|
|
59
|
+
"target": "components/shell/theme-provider.tsx"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "base/app-shell/components/page-transition.tsx",
|
|
63
|
+
"content": "\"use client\";\n\n/**\n * The page region of the shell. Moving between sections — dashboard to\n * chat, chat to artifacts — lifts the new page in; moving inside one\n * (a conversation to the next, a settings tab to another) does not,\n * because the section's own layout owns that motion.\n *\n * It animates in place rather than keying on the route, so nothing under\n * it remounts: a layout's state survives the navigation.\n */\n\nimport * as React from \"react\";\nimport { useAnimate, useReducedMotion } from \"motion/react\";\n\nimport { EASE_OUT } from \"@/components/ui/ai-motion\";\nimport { usePathname } from \"@/i18n/navigation\";\nimport { cn } from \"@/lib/utils\";\n\nfunction sectionOf(pathname: string | null) {\n return pathname?.split(\"/\").filter(Boolean)[0] ?? \"\";\n}\n\nexport function PageTransition({\n className,\n children,\n}: {\n className?: string;\n children: React.ReactNode;\n}) {\n const pathname = usePathname();\n const reduced = useReducedMotion() ?? false;\n const [scope, animate] = useAnimate<HTMLDivElement>();\n const section = sectionOf(pathname);\n const previous = React.useRef(section);\n\n React.useEffect(() => {\n if (previous.current === section) return;\n previous.current = section;\n scope.current?.scrollTo({ top: 0 });\n if (reduced) return;\n const element = scope.current;\n // Cleared after: a leftover transform would make this region the\n // containing block of every fixed-position element inside the page.\n void animate(\n element,\n { opacity: [0, 1], y: [8, 0] },\n { duration: 0.36, ease: EASE_OUT }\n ).then(() => {\n element.style.transform = \"\";\n element.style.opacity = \"\";\n });\n }, [animate, reduced, scope, section]);\n\n return (\n <div ref={scope} data-slot=\"page\" className={cn(className)}>\n {children}\n </div>\n );\n}\n",
|
|
64
|
+
"type": "registry:component",
|
|
65
|
+
"target": "components/shell/page-transition.tsx"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "base/app-shell/components/time-zone-cookie.tsx",
|
|
69
|
+
"content": "\"use client\";\n\n/**\n * Writes the browser's time zone to a cookie so per-day figures bucket\n * in the reader's day rather than UTC. The server reads it through\n * `@intelligo-dev/core/request-context`; a browser's time zone reaches the\n * server no other way. The first render of a new session has no cookie\n * yet and buckets in UTC.\n */\n\nimport { useEffect } from \"react\";\n\nconst COOKIE = \"tz\";\nconst ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nexport function TimeZoneCookie() {\n useEffect(() => {\n const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n if (!zone) return;\n\n const current = document.cookie.match(/(?:^|;\\s*)tz=([^;]*)/)?.[1];\n if (current && decodeURIComponent(current) === zone) return;\n\n // Lax: this rides same-site navigation, and it is a display\n // preference, not a credential.\n document.cookie = `${COOKIE}=${encodeURIComponent(zone)}; path=/; max-age=${ONE_YEAR_SECONDS}; samesite=lax`;\n }, []);\n\n return null;\n}\n",
|
|
70
|
+
"type": "registry:component",
|
|
71
|
+
"target": "components/shell/time-zone-cookie.tsx"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
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",
|
|
76
|
+
"type": "registry:file",
|
|
77
|
+
"target": "lib/shell-config.tsx"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "base/app-shell/messages/en.json",
|
|
81
|
+
"content": "{\n \"skipToContent\": \"Skip to content\",\n \"sidebar\": {\n \"label\": \"Main navigation\",\n \"toggle\": \"Toggle sidebar\",\n \"close\": \"Close sidebar\",\n \"nav\": {\n \"dashboard\": \"Dashboard\",\n \"chat\": \"Chat\",\n \"artifacts\": \"Artifacts\"\n }\n },\n \"userMenu\": {\n \"profile\": \"Profile\",\n \"workspaceSettings\": \"Workspace settings\",\n \"usage\": \"Usage\",\n \"pricing\": \"Pricing\",\n \"theme\": \"Theme\",\n \"themeLight\": \"Light\",\n \"themeDark\": \"Dark\",\n \"themeSystem\": \"System\",\n \"signOut\": \"Sign out\"\n },\n \"workspaceSwitcher\": {\n \"workspacesLabel\": \"Workspaces\",\n \"switchFailed\": \"Couldn't switch workspace. Please try again.\"\n }\n}\n",
|
|
82
|
+
"type": "registry:file",
|
|
83
|
+
"target": "messages/en/app-shell.json"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"path": "base/app-shell/lib/nav-config.ts",
|
|
87
|
+
"content": "/**\n * Shell navigation. Imported directly by client components, never passed\n * from a Server Component: icons are components and cannot cross the\n * server/client boundary.\n *\n * `navItems` fills the sidebar; `accountItems` fills the user menu.\n * `titleKey`s are keys in `messages/<locale>/app-shell.json`. Remove the\n * entries whose routes you did not install; they would 404.\n */\n\nimport {\n BarChart3,\n CreditCard,\n FileText,\n LayoutDashboard,\n MessageSquare,\n} from \"lucide-react\";\n\nimport type { NavItem } from \"@/components/shell/app-sidebar\";\n\n/** The sidebar: the product's own surfaces. */\nexport const navItems: NavItem[] = [\n {\n titleKey: \"sidebar.nav.dashboard\",\n href: \"/dashboard\",\n icon: LayoutDashboard,\n },\n { titleKey: \"sidebar.nav.chat\", href: \"/chat\", icon: MessageSquare },\n { titleKey: \"sidebar.nav.artifacts\", href: \"/artifacts\", icon: FileText },\n];\n\n/** The user menu: the account's own pages. */\nexport const accountItems: NavItem[] = [\n { titleKey: \"userMenu.usage\", href: \"/usage\", icon: BarChart3 },\n { titleKey: \"userMenu.pricing\", href: \"/pricing\", icon: CreditCard },\n];\n",
|
|
88
|
+
"type": "registry:file",
|
|
89
|
+
"target": "lib/nav-config.ts"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"type": "registry:block"
|
|
93
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "artifacts",
|
|
4
|
+
"title": "Artifacts",
|
|
5
|
+
"description": "Browse and manage AI-generated document artifacts: filterable list, full-content preview, copy-to-clipboard, and version delete, backed by the framework's document persistence service.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"@intelligo-dev/core",
|
|
9
|
+
"@intelligo-dev/next",
|
|
10
|
+
"lucide-react",
|
|
11
|
+
"sonner",
|
|
12
|
+
"server-only",
|
|
13
|
+
"next-intl"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"alert",
|
|
17
|
+
"badge",
|
|
18
|
+
"@intelligo/button",
|
|
19
|
+
"card",
|
|
20
|
+
"@intelligo/dialog",
|
|
21
|
+
"scroll-area",
|
|
22
|
+
"skeleton",
|
|
23
|
+
"@intelligo/spinner",
|
|
24
|
+
"@intelligo/page-header",
|
|
25
|
+
"@intelligo/document-viewer",
|
|
26
|
+
"@intelligo/animated-list"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
{
|
|
30
|
+
"path": "base/artifacts/page.tsx",
|
|
31
|
+
"content": "import type { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport {\n Card,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\";\n\nimport { listDocuments } from \"@/actions/documents\";\nimport { DocumentList } from \"@/components/artifacts/document-list\";\nimport {\n PageHeader,\n PageHeaderContent,\n PageHeaderDescription,\n PageHeaderTitle,\n} from \"@/components/ui/page-header\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"artifacts\");\n return { title: t(\"page.title\") };\n}\n\n/**\n * Every artifact the user created in the active workspace, fetched once;\n * filtering and the preview dialog are client-side.\n *\n * `force-dynamic` because `listDocuments()` reads the session, so a\n * static prerender would fail at build time.\n */\nexport const dynamic = \"force-dynamic\";\n\nexport default async function ArtifactsPage() {\n const [t, result] = await Promise.all([\n getTranslations(\"artifacts\"),\n listDocuments(),\n ]);\n\n return (\n <div className=\"container mx-auto space-y-8 px-4 py-8\">\n <PageHeader>\n <PageHeaderContent>\n <PageHeaderTitle>{t(\"page.heading\")}</PageHeaderTitle>\n <PageHeaderDescription>{t(\"page.description\")}</PageHeaderDescription>\n </PageHeaderContent>\n </PageHeader>\n\n {result.success ? (\n <DocumentList documents={result.data} />\n ) : (\n <Card>\n <CardHeader>\n <CardTitle>{t(\"page.unavailableTitle\")}</CardTitle>\n <CardDescription>{result.error}</CardDescription>\n </CardHeader>\n </Card>\n )}\n </div>\n );\n}\n",
|
|
32
|
+
"type": "registry:page",
|
|
33
|
+
"target": "app/[locale]/(app)/artifacts/page.tsx"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "base/artifacts/loading.tsx",
|
|
37
|
+
"content": "import { Card } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function ArtifactsLoading() {\n return (\n <div className=\"container mx-auto space-y-8 px-4 py-8\">\n <div className=\"space-y-2\">\n <Skeleton className=\"h-7 w-32\" />\n <Skeleton className=\"h-4 w-80\" />\n </div>\n\n <div className=\"flex flex-wrap gap-2\">\n {Array.from({ length: 3 }).map((_, index) => (\n <Skeleton key={index} className=\"h-8 w-20 rounded-full\" />\n ))}\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-3\">\n {Array.from({ length: 6 }).map((_, index) => (\n <Card key={index} className=\"p-5\">\n <div className=\"mb-3 flex items-start gap-3\">\n <Skeleton className=\"size-4 rounded\" />\n <div className=\"min-w-0 flex-1 space-y-2\">\n <Skeleton className=\"h-5 w-48\" />\n <Skeleton className=\"h-4 w-16 rounded-full\" />\n </div>\n </div>\n <div className=\"flex items-center justify-between\">\n <Skeleton className=\"h-4 w-28\" />\n <Skeleton className=\"h-7 w-16 rounded\" />\n </div>\n </Card>\n ))}\n </div>\n </div>\n );\n}\n",
|
|
38
|
+
"type": "registry:page",
|
|
39
|
+
"target": "app/[locale]/(app)/artifacts/loading.tsx"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "base/artifacts/error.tsx",
|
|
43
|
+
"content": "\"use client\";\n\n/** Error boundary for the artifacts route. */\n\nimport { useEffect } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Link } from \"@/i18n/navigation\";\n\nexport default function ArtifactsError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n const t = useTranslations(\"artifacts\");\n\n useEffect(() => {\n console.error(\"[artifacts] error boundary caught:\", error);\n }, [error]);\n\n return (\n <div className=\"flex min-h-96 items-center justify-center p-4\">\n <div className=\"w-full max-w-md space-y-4\">\n <Alert variant=\"destructive\">\n <AlertDescription>{t(\"error.description\")}</AlertDescription>\n </Alert>\n\n <div className=\"flex justify-center gap-3\">\n <Button onClick={reset}>{t(\"error.retry\")}</Button>\n <Button\n variant=\"outline\"\n render={<Link href=\"/\" />}\n nativeButton={false}\n >\n {t(\"error.goHome\")}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n",
|
|
44
|
+
"type": "registry:page",
|
|
45
|
+
"target": "app/[locale]/(app)/artifacts/error.tsx"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "base/artifacts/actions.ts",
|
|
49
|
+
"content": "\"use server\";\n\n/**\n * Artifact library actions over `@intelligo-dev/core/documents`: resolve\n * the caller with `requireWorkspace()`, call the service, map a\n * `DocumentServiceError` to a translated message.\n *\n * Deleting uses `deleteDocumentVersions(actor, id, timestamp)`, which\n * removes every version created strictly after `timestamp`, within the\n * last 30 days. It is a revert, not a hard delete: `deleteLatestVersion`\n * passes one millisecond before the version this page shows, so an\n * artifact with older versions reverts to the previous one, and one\n * with a single version disappears.\n */\n\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport {\n deleteDocumentVersions,\n getUserDocuments,\n isDocumentServiceError,\n isProductDocument,\n type DocumentListItem,\n} from \"@intelligo-dev/core/documents\";\nimport type { ActionResult as BaseActionResult } from \"@intelligo-dev/next\";\n\nexport type ActionResult<T> = BaseActionResult<T>;\n\n/**\n * A document list item plus whether its title matches a pattern\n * registered through `@/lib/document-patterns.ts` — drives the\n * \"Reports\" filter tab.\n */\nexport type ArtifactListItem = DocumentListItem & { isReport: boolean };\n\ntype Translator = Awaited<ReturnType<typeof getTranslations>>;\n\nfunction friendlyMessageKey(code: string): string | undefined {\n switch (code) {\n case \"not_found\":\n return \"actions.notFound\";\n case \"forbidden\":\n return \"actions.forbidden\";\n case \"invalid_input\":\n return \"actions.invalidInput\";\n case \"database_error\":\n return \"actions.databaseError\";\n default:\n return undefined;\n }\n}\n\nfunction friendlyError(t: Translator, error: unknown): string {\n // Unknown errors deliberately map to the generic key — a raw\n // `Error#message` can carry internals (SQL, hostnames) to the UI.\n if (isDocumentServiceError(error)) {\n const key = friendlyMessageKey(error.code);\n return key ? t(key) : t(\"actions.genericError\");\n }\n return t(\"actions.genericError\");\n}\n\n/**\n * Every artifact the caller owns in the active workspace, newest first.\n * Filtering happens client-side.\n */\nexport async function listDocuments(): Promise<\n ActionResult<ArtifactListItem[]>\n> {\n try {\n const { workspace, user } = await requireWorkspace();\n const documents = await getUserDocuments({\n workspaceId: workspace.id,\n userId: user.id,\n });\n\n return {\n success: true,\n data: documents.map((doc) => ({\n ...doc,\n isReport: isProductDocument(doc.title),\n })),\n };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"artifacts\"), error),\n };\n }\n}\n\n/**\n * Deletes the version of the artifact this page shows (see the module\n * comment). `latestCreatedAt` is that version's `createdAt` as an ISO\n * string, as `listDocuments` returns it.\n */\nexport async function deleteLatestVersion(\n id: string,\n latestCreatedAt: string\n): Promise<ActionResult<undefined>> {\n const latest = new Date(latestCreatedAt);\n if (Number.isNaN(latest.getTime())) {\n const t = await getTranslations(\"artifacts\");\n return { success: false, error: t(\"actions.invalidTimestamp\") };\n }\n\n try {\n const { workspace, user } = await requireWorkspace();\n const cutoff = new Date(latest.getTime() - 1).toISOString();\n\n await deleteDocumentVersions(\n { workspaceId: workspace.id, userId: user.id },\n id,\n cutoff\n );\n\n return { success: true, data: undefined };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"artifacts\"), error),\n };\n }\n}\n",
|
|
50
|
+
"type": "registry:file",
|
|
51
|
+
"target": "actions/documents.ts"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "base/artifacts/lib/document-patterns.ts",
|
|
55
|
+
"content": "import \"server-only\";\n\n/**\n * Title patterns that map a document to an agent label (shown on its\n * card) and to the \"Reports\" tab. With none registered, every document\n * gets the generic \"AI Assistant\" label.\n *\n * Call this exactly once, from your composition root: calling it from a\n * request-scoped file (an action, a page) would push duplicate entries\n * onto the same in-memory registry.\n */\nexport function registerDefaultDocumentPatterns(): void {\n // Registers nothing by default.\n}\n\n// Example: label your agent's documents and put them under \"Reports\"\n// (replace the export above):\n//\n// import { registerDocumentPatterns } from \"@intelligo-dev/core/documents\";\n//\n// export function registerDefaultDocumentPatterns(): void {\n// registerDocumentPatterns({\n// productSlug: \"default\",\n// agentLabel: \"Assistant\",\n// patterns: [\"report\", \"summary\"],\n// });\n// }\n",
|
|
56
|
+
"type": "registry:file",
|
|
57
|
+
"target": "lib/document-patterns.ts"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "base/artifacts/components/document-list.tsx",
|
|
61
|
+
"content": "\"use client\";\n\n/**\n * The artifact library: a grid of document cards and a reader dialog.\n * Documents render through `@/components/ui/document-viewer`, as in the\n * chat canvas.\n *\n * The \"Reports\" tab filters on `doc.isReport`, computed server-side from\n * the patterns in `@/lib/document-patterns`; the kind tabs come from the\n * kinds present, so a custom kind gets its own tab. A deleted item is\n * dropped from local state once the server action succeeds.\n */\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\nimport { Bot, ExternalLink, File } from \"lucide-react\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n DocumentView,\n HtmlPreview,\n documentKindIcon,\n extensionOf,\n isPreviewableTitle,\n} from \"@/components/ui/document-viewer\";\nimport { AnimatedList, AnimatedListItem } from \"@/components/ui/animated-list\";\n\nimport type { ArtifactListItem } from \"@/actions/documents\";\nimport { DocumentActions } from \"./document-actions\";\n\n/** How much of a document a card shows before it fades out. */\nconst PREVIEW_LINES = 8;\n\nfunction head(text: string): string {\n return text.trimStart().split(\"\\n\").slice(0, PREVIEW_LINES).join(\"\\n\");\n}\n\n/**\n * An inline style rather than a class, because the design system keeps\n * gradients out of class names. It fades the bottom: a card shows a\n * document's opening lines.\n */\nconst PREVIEW_MASK = \"linear-gradient(to bottom, black 60%, transparent)\";\n\ninterface DocumentListProps {\n documents: ArtifactListItem[];\n}\n\n/**\n * Relative timestamp that can't cause a hydration mismatch: the server\n * (and the first client render) show a locale-formatted absolute date,\n * and an effect swaps in the relative form once mounted — `Date.now()`\n * during render would differ between server and client.\n */\nfunction RelativeTime({ iso }: { iso: string }) {\n const format = useFormatter();\n const [now, setNow] = useState<Date | null>(null);\n useEffect(() => setNow(new Date()), []);\n const date = new Date(iso);\n return (\n <>\n {now\n ? format.relativeTime(date, now)\n : format.dateTime(date, { dateStyle: \"medium\" })}\n </>\n );\n}\n\n/** The kind's glyph on the tile both the card and the reader use. */\nfunction KindTile({ kind, large }: { kind: string; large?: boolean }) {\n const Icon = documentKindIcon(kind);\n return (\n <span\n aria-hidden=\"true\"\n className={\n large\n ? \"grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground\"\n : \"grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground\"\n }\n >\n <Icon className={large ? \"size-5\" : \"size-4\"} />\n </span>\n );\n}\n\nexport function DocumentList({ documents }: DocumentListProps) {\n const t = useTranslations(\"artifacts\");\n const [items, setItems] = useState(documents);\n const [activeFilter, setActiveFilter] = useState<string>(\"all\");\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [view, setView] = useState<\"preview\" | \"source\">(\"preview\");\n\n // `/artifacts?document=<id>` — the chat canvas's \"Open in Artifacts\" —\n // lands on that document's preview.\n useEffect(() => {\n const id = new URLSearchParams(window.location.search).get(\"document\");\n if (id) setSelectedId(id);\n }, []);\n\n // Each document opens on its rendered form, not on whatever the last\n // one was left showing.\n useEffect(() => setView(\"preview\"), [selectedId]);\n\n /**\n * A known kind (text/code/sheet/image) resolves through this item's\n * own messages; a product-defined custom kind falls back to\n * capitalizing the raw value — `t.has` is the check next-intl exposes\n * for \"does this key exist\" without throwing.\n */\n function kindLabel(kind: string): string {\n const key = `filters.kind.${kind}`;\n return t.has(key) ? t(key) : kind.charAt(0).toUpperCase() + kind.slice(1);\n }\n\n /** \"Code · py\" — the same subtitle the chat's artifact card writes. */\n function subtitleOf(doc: ArtifactListItem): string {\n const extension = doc.kind === \"code\" ? extensionOf(doc.title) : undefined;\n return [kindLabel(doc.kind), extension].filter(Boolean).join(\" · \");\n }\n\n const kinds = useMemo(\n () => Array.from(new Set(items.map((doc) => doc.kind))).sort(),\n [items]\n );\n const hasReports = items.some((doc) => doc.isReport);\n const selected = items.find((doc) => doc.id === selectedId) ?? null;\n\n const filters: { type: string; label: string; count: number }[] = [\n { type: \"all\", label: t(\"filters.all\"), count: items.length },\n ...(hasReports\n ? [\n {\n type: \"report\",\n label: t(\"filters.reports\"),\n count: items.filter((doc) => doc.isReport).length,\n },\n ]\n : []),\n ...kinds.map((kind) => ({\n type: kind,\n label: kindLabel(kind),\n count: items.filter((doc) => doc.kind === kind).length,\n })),\n ];\n\n const filtered = items.filter((doc) => {\n if (activeFilter === \"all\") return true;\n if (activeFilter === \"report\") return doc.isReport;\n return doc.kind === activeFilter;\n });\n\n function handleDeleted(id: string) {\n setItems((prev) => prev.filter((doc) => doc.id !== id));\n setSelectedId((prev) => (prev === id ? null : prev));\n }\n\n if (items.length === 0) {\n return <EmptyState />;\n }\n\n const activeFilterLabel =\n filters.find((filter) => filter.type === activeFilter)?.label ??\n activeFilter;\n\n // An HTML page or an SVG has a rendered form worth offering; anything\n // else is only ever its source.\n const canPreview = selected ? isPreviewableTitle(selected.title) : false;\n\n return (\n <>\n {/* One segmented control, not a row of pill buttons: these are\n views of one list, and the counts say what is behind each. */}\n <div\n role=\"group\"\n aria-label={t(\"filters.all\")}\n className=\"flex w-fit max-w-full flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5\"\n >\n {filters.map((filter) => {\n const active = activeFilter === filter.type;\n return (\n <button\n key={filter.type}\n type=\"button\"\n aria-pressed={active}\n onClick={() => setActiveFilter(filter.type)}\n className={`h-7 rounded-md px-2.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring ${\n active\n ? \"bg-background text-foreground shadow-xs\"\n : \"text-muted-foreground hover:text-foreground\"\n }`}\n >\n {filter.label}\n <span className=\"ml-1.5 tabular-nums opacity-60\">\n {filter.count}\n </span>\n </button>\n );\n })}\n </div>\n\n {filtered.length === 0 ? (\n <FilteredEmptyState filterLabel={activeFilterLabel} />\n ) : (\n <AnimatedList\n as=\"div\"\n className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-3\"\n >\n {filtered.map((doc) => {\n const previewText = doc.content ? head(doc.content) : \"\";\n return (\n <AnimatedListItem\n as=\"div\"\n key={doc.id}\n className=\"group flex cursor-pointer flex-col overflow-hidden rounded-xl border bg-card transition-colors hover:border-foreground/20 focus-within:border-foreground/20\"\n onClick={() => setSelectedId(doc.id)}\n >\n {/* The document itself, clipped under a fade. Decorative:\n the title below is the accessible name. */}\n {doc.kind === \"image\" && doc.content ? (\n <div className=\"h-28 overflow-hidden border-b bg-muted/30\">\n <img\n src={doc.content}\n alt=\"\"\n aria-hidden=\"true\"\n className=\"size-full object-cover\"\n />\n </div>\n ) : previewText ? (\n <div\n aria-hidden=\"true\"\n className=\"h-28 overflow-hidden border-b bg-muted/30 px-4 pt-3 font-mono text-xs leading-4 whitespace-pre-wrap text-muted-foreground\"\n style={{\n maskImage: PREVIEW_MASK,\n WebkitMaskImage: PREVIEW_MASK,\n }}\n >\n {previewText}\n </div>\n ) : (\n <div className=\"grid h-28 place-items-center border-b bg-muted/30\">\n <File\n aria-hidden=\"true\"\n className=\"size-6 text-muted-foreground/40\"\n />\n </div>\n )}\n\n <div className=\"flex min-w-0 items-start gap-3 p-4\">\n <KindTile kind={doc.kind} />\n <div className=\"min-w-0 flex-1\">\n {/* A real button, so the card is reachable and\n named without nesting controls inside it. */}\n <button\n type=\"button\"\n onClick={() => setSelectedId(doc.id)}\n className=\"line-clamp-2 text-left text-sm leading-tight font-medium outline-none focus-visible:underline\"\n >\n {doc.title}\n </button>\n <p className=\"mt-1 truncate text-xs text-muted-foreground\">\n {subtitleOf(doc)}\n </p>\n <div className=\"mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n <Bot className=\"size-3 shrink-0\" />\n <span className=\"truncate\">{doc.agentLabel}</span>\n <span className=\"shrink-0\">·</span>\n <span className=\"shrink-0\">\n <RelativeTime iso={doc.createdAt} />\n </span>\n </div>\n </div>\n\n {doc.isReport ? (\n <Badge variant=\"secondary\">{t(\"badge.report\")}</Badge>\n ) : null}\n\n <DocumentActions\n documentId={doc.id}\n title={doc.title}\n kind={doc.kind}\n content={doc.content}\n createdAt={doc.createdAt}\n onDeleted={() => handleDeleted(doc.id)}\n buttonClassName=\"size-7 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100\"\n />\n </div>\n </AnimatedListItem>\n );\n })}\n </AnimatedList>\n )}\n\n <Dialog\n open={selected !== null}\n onOpenChange={(open) => !open && setSelectedId(null)}\n >\n {selected && (\n /* `sm:` matters: DialogContent's base classes include\n `sm:max-w-sm`, which tailwind-merge does not replace with an\n unprefixed `max-w-*`, so that would win above 640px. */\n <DialogContent className=\"flex max-h-[80vh] w-full flex-col sm:max-w-5xl\">\n <DialogHeader>\n <div className=\"flex items-start gap-3 pr-8\">\n <KindTile kind={selected.kind} large />\n <div className=\"min-w-0 flex-1\">\n <DialogTitle className=\"text-left leading-tight\">\n {selected.title}\n </DialogTitle>\n <p className=\"mt-1 truncate text-xs text-muted-foreground\">\n {subtitleOf(selected)} · {selected.agentLabel} ·{\" \"}\n <RelativeTime iso={selected.createdAt} />\n </p>\n </div>\n </div>\n\n {/* Second row, as in the chat canvas: how to look at the\n document, then what to do with it. */}\n <div className=\"flex min-w-0 flex-wrap items-center gap-2 pt-1\">\n {canPreview ? (\n <div\n role=\"group\"\n aria-label={t(\"preview.view\")}\n className=\"flex items-center rounded-lg bg-muted p-0.5\"\n >\n {([\"preview\", \"source\"] as const).map((option) => (\n <button\n key={option}\n type=\"button\"\n aria-pressed={view === option}\n onClick={() => setView(option)}\n className={`h-6 rounded-md px-2 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring ${\n view === option\n ? \"bg-background text-foreground shadow-xs\"\n : \"text-muted-foreground hover:text-foreground\"\n }`}\n >\n {option === \"preview\"\n ? t(\"preview.rendered\")\n : t(\"preview.source\")}\n </button>\n ))}\n </div>\n ) : null}\n\n <div className=\"ml-auto flex items-center gap-1\">\n {selected.conversationId ? (\n <Button\n size=\"sm\"\n variant=\"outline\"\n render={\n <Link href={`/chat/${selected.conversationId}`} />\n }\n nativeButton={false}\n >\n <ExternalLink data-icon=\"inline-start\" />\n {t(\"preview.openConversation\")}\n </Button>\n ) : null}\n <DocumentActions\n documentId={selected.id}\n title={selected.title}\n kind={selected.kind}\n content={selected.content}\n createdAt={selected.createdAt}\n onDeleted={() => handleDeleted(selected.id)}\n />\n </div>\n </div>\n </DialogHeader>\n\n {/* `flex flex-col` is what makes this scroll: ScrollArea's\n viewport is `size-full`, and a percentage height against\n a shrunk flex item resolves to `auto`, so a long document\n would run past the dialog. A flex parent sizes it by\n layout instead. */}\n <ScrollArea className=\"mt-2 flex min-h-0 flex-1 flex-col\">\n {selected.content ? (\n canPreview && view === \"preview\" ? (\n <HtmlPreview\n content={selected.content}\n title={selected.title}\n />\n ) : (\n <DocumentView\n kind={selected.kind}\n title={selected.title}\n content={selected.content}\n />\n )\n ) : (\n <p className=\"text-sm italic text-muted-foreground\">\n {t(\"noContent\")}\n </p>\n )}\n </ScrollArea>\n </DialogContent>\n )}\n </Dialog>\n </>\n );\n}\n\nfunction EmptyState() {\n const t = useTranslations(\"artifacts\");\n return (\n <div className=\"flex flex-col items-center justify-center py-16 text-center\">\n <File className=\"mb-4 size-12 text-muted-foreground/40\" />\n <h3 className=\"mb-1 text-lg font-semibold\">{t(\"emptyState.title\")}</h3>\n <p className=\"max-w-sm text-sm text-muted-foreground\">\n {t(\"emptyState.description\")}\n </p>\n {/* An empty artifacts page is a dead end without this: nothing\n on it produces an artifact — the chat surface does. */}\n <Button\n size=\"sm\"\n className=\"mt-4\"\n render={<Link href=\"/chat\" />}\n nativeButton={false}\n >\n {t(\"emptyState.cta\")}\n </Button>\n </div>\n );\n}\n\nfunction FilteredEmptyState({ filterLabel }: { filterLabel: string }) {\n const t = useTranslations(\"artifacts\");\n const lowerFilterLabel = filterLabel.toLowerCase();\n return (\n <div className=\"flex flex-col items-center justify-center py-16 text-center\">\n <File className=\"mb-4 size-12 text-muted-foreground/40\" />\n <h3 className=\"mb-1 text-lg font-semibold\">\n {t(\"filteredEmptyState.title\", { filterLabel: lowerFilterLabel })}\n </h3>\n <p className=\"text-sm text-muted-foreground\">\n {t(\"filteredEmptyState.description\", { filterLabel })}\n </p>\n </div>\n );\n}\n",
|
|
62
|
+
"type": "registry:component",
|
|
63
|
+
"target": "components/artifacts/document-list.tsx"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "base/artifacts/components/document-actions.tsx",
|
|
67
|
+
"content": "\"use client\";\n\n/**\n * Per-artifact actions: copy, download, and delete. Delete reverts to an\n * earlier saved version when one exists rather than removing the\n * artifact, and refuses artifacts older than 30 days; the confirm dialog\n * says so.\n */\n\nimport { useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { Check, Copy, Download, Trash2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n documentKindExtension,\n fileNameOf,\n} from \"@/components/ui/document-viewer\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nimport { deleteLatestVersion } from \"@/actions/documents\";\n\ninterface DocumentActionsProps {\n documentId: string;\n title: string;\n kind: string;\n content: string | null;\n createdAt: string;\n /** Called after a successful delete, e.g. to drop the item from local state. */\n onDeleted?: () => void;\n buttonClassName?: string;\n}\n\nexport function DocumentActions({\n documentId,\n title,\n kind,\n content,\n createdAt,\n onDeleted,\n buttonClassName,\n}: DocumentActionsProps) {\n const t = useTranslations(\"artifacts\");\n const router = useRouter();\n const [copied, setCopied] = useState(false);\n const [confirmOpen, setConfirmOpen] = useState(false);\n const [isDeleting, startDeleting] = useTransition();\n\n /**\n * Saved the way the canvas saves it: an image is already a URL, and\n * everything else becomes a blob typed by its kind, so a sheet lands\n * as a `.csv` a spreadsheet will open rather than as plain text.\n */\n function handleDownload() {\n if (!content) return;\n const link = document.createElement(\"a\");\n if (kind === \"image\") {\n link.href = content;\n } else {\n link.href = URL.createObjectURL(\n new Blob([content], {\n type: kind === \"sheet\" ? \"text/csv\" : \"text/plain\",\n })\n );\n }\n link.download = fileNameOf(title, documentKindExtension(kind));\n link.click();\n if (link.href.startsWith(\"blob:\")) URL.revokeObjectURL(link.href);\n }\n\n async function handleCopy() {\n if (!content) return;\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n toast.success(t(\"documentActions.copySuccess\"));\n setTimeout(() => setCopied(false), 2000);\n } catch {\n toast.error(t(\"documentActions.copyErrorTitle\"), {\n description: t(\"documentActions.copyErrorDescription\"),\n });\n }\n }\n\n function handleDelete() {\n startDeleting(async () => {\n const result = await deleteLatestVersion(documentId, createdAt);\n if (!result.success) {\n toast.error(result.error);\n return;\n }\n toast.success(t(\"documentActions.deleteSuccess\"));\n setConfirmOpen(false);\n onDeleted?.();\n router.refresh();\n });\n }\n\n return (\n <div className=\"flex items-center gap-1\">\n {content && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={(event) => {\n event.stopPropagation();\n void handleCopy();\n }}\n className={buttonClassName}\n title={\n copied\n ? t(\"documentActions.copyTooltipCopied\")\n : t(\"documentActions.copyTooltipDefault\")\n }\n >\n {copied ? (\n <Check className=\"size-4 text-success\" />\n ) : (\n <Copy className=\"size-4\" />\n )}\n </Button>\n )}\n\n {content && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={(event) => {\n event.stopPropagation();\n handleDownload();\n }}\n className={buttonClassName}\n title={t(\"documentActions.downloadTooltip\")}\n >\n <Download className=\"size-4\" />\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={(event) => {\n event.stopPropagation();\n setConfirmOpen(true);\n }}\n className={buttonClassName}\n title={t(\"documentActions.deleteTooltip\")}\n >\n <Trash2 className=\"size-4\" />\n </Button>\n\n <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>\n <DialogContent onClick={(event) => event.stopPropagation()}>\n <DialogHeader>\n <DialogTitle>{t(\"documentActions.deleteDialogTitle\")}</DialogTitle>\n <DialogDescription>\n {t(\"documentActions.deleteDialogDescription\")}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => setConfirmOpen(false)}\n disabled={isDeleting}\n >\n {t(\"documentActions.cancel\")}\n </Button>\n <Button\n type=\"button\"\n variant=\"destructive\"\n onClick={handleDelete}\n disabled={isDeleting}\n >\n {isDeleting ? <Spinner /> : t(\"documentActions.delete\")}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </div>\n );\n}\n",
|
|
68
|
+
"type": "registry:component",
|
|
69
|
+
"target": "components/artifacts/document-actions.tsx"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"path": "base/artifacts/messages/en.json",
|
|
73
|
+
"content": "{\n \"page\": {\n \"title\": \"Artifacts\",\n \"heading\": \"Artifacts\",\n \"description\": \"All the documents your AI agents have created for you, in one place.\",\n \"unavailableTitle\": \"Artifacts unavailable\"\n },\n \"actions\": {\n \"notFound\": \"This artifact no longer exists.\",\n \"forbidden\": \"You don't have permission to change this artifact.\",\n \"invalidInput\": \"This artifact is more than 30 days old and can't be deleted from here.\",\n \"databaseError\": \"Something went wrong. Please try again.\",\n \"genericError\": \"Something went wrong.\",\n \"invalidTimestamp\": \"Invalid artifact timestamp.\"\n },\n \"error\": {\n \"description\": \"Something went wrong while loading your artifacts. Please try again.\",\n \"retry\": \"Try again\",\n \"goHome\": \"Go home\"\n },\n \"filters\": {\n \"all\": \"All\",\n \"reports\": \"Reports\",\n \"kind\": {\n \"text\": \"Text\",\n \"code\": \"Code\",\n \"sheet\": \"Sheet\",\n \"image\": \"Image\"\n }\n },\n \"badge\": {\n \"report\": \"Report\"\n },\n \"emptyState\": {\n \"title\": \"No artifacts yet\",\n \"description\": \"Artifacts your AI agents create — reports, code, text, and more — will show up here.\",\n \"cta\": \"Start a chat\"\n },\n \"filteredEmptyState\": {\n \"title\": \"No {filterLabel} artifacts\",\n \"description\": \"No artifacts match the “{filterLabel}” filter. Try a different filter.\"\n },\n \"preview\": {\n \"view\": \"View\",\n \"rendered\": \"Preview\",\n \"source\": \"Source\",\n \"openConversation\": \"Open conversation\"\n },\n \"documentActions\": {\n \"copySuccess\": \"Copied to clipboard\",\n \"copyErrorTitle\": \"Failed to copy\",\n \"copyErrorDescription\": \"Could not copy to clipboard. Please try again.\",\n \"copyTooltipCopied\": \"Copied!\",\n \"copyTooltipDefault\": \"Copy to clipboard\",\n \"downloadTooltip\": \"Download\",\n \"deleteTooltip\": \"Delete\",\n \"deleteDialogTitle\": \"Delete artifact\",\n \"deleteDialogDescription\": \"This removes the version shown here. If this artifact has earlier saved versions, it reverts to the previous one instead of disappearing entirely. Artifacts older than 30 days can't be deleted from this page.\",\n \"cancel\": \"Cancel\",\n \"delete\": \"Delete\",\n \"deleteSuccess\": \"Artifact deleted\"\n },\n \"noContent\": \"No content available for this artifact.\"\n}\n",
|
|
74
|
+
"type": "registry:file",
|
|
75
|
+
"target": "messages/en/artifacts.json"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"css": {
|
|
79
|
+
"@source \"../node_modules/streamdown/dist/*.js\"": {}
|
|
80
|
+
},
|
|
81
|
+
"type": "registry:block"
|
|
82
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "attachment",
|
|
4
|
+
"title": "Attachment",
|
|
5
|
+
"description": "A file attached to a message or a prompt.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@base-ui/react",
|
|
8
|
+
"class-variance-authority"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@intelligo/button"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "base/ui/attachment/attachment.tsx",
|
|
16
|
+
"content": "import * as React from \"react\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nconst attachmentVariants = cva(\n \"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-lg border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed\",\n {\n variants: {\n size: {\n default:\n \"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2\",\n sm: \"gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5\",\n xs: \"gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1\",\n },\n orientation: {\n horizontal: \"min-w-40 items-center\",\n vertical: \"w-24 flex-col has-data-[slot=attachment-content]:w-30\",\n },\n },\n }\n);\n\nfunction Attachment({\n className,\n state = \"done\",\n size = \"default\",\n orientation = \"horizontal\",\n ...props\n}: React.ComponentProps<\"div\"> &\n VariantProps<typeof attachmentVariants> & {\n state?: \"idle\" | \"uploading\" | \"processing\" | \"error\" | \"done\";\n }) {\n return (\n <div\n data-slot=\"attachment\"\n data-state={state}\n data-size={size}\n data-orientation={orientation}\n className={cn(attachmentVariants({ size, orientation }), className)}\n {...props}\n />\n );\n}\n\nconst attachmentMediaVariants = cva(\n \"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5\",\n {\n variants: {\n variant: {\n icon: \"\",\n image:\n \"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover\",\n },\n },\n defaultVariants: {\n variant: \"icon\",\n },\n }\n);\n\nfunction AttachmentMedia({\n className,\n variant = \"icon\",\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof attachmentMediaVariants>) {\n return (\n <div\n data-slot=\"attachment-media\"\n data-variant={variant}\n className={cn(attachmentMediaVariants({ variant }), className)}\n {...props}\n />\n );\n}\n\nfunction AttachmentContent({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"attachment-content\"\n className={cn(\n \"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AttachmentTitle({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"attachment-title\"\n className={cn(\n \"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AttachmentDescription({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n <span\n data-slot=\"attachment-description\"\n className={cn(\n \"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80\",\n \"max-w-full\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AttachmentActions({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"attachment-actions\"\n className={cn(\n \"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1\",\n className\n )}\n {...props}\n />\n );\n}\n\nfunction AttachmentAction({\n className,\n variant,\n size = \"icon-xs\",\n ...props\n}: React.ComponentProps<typeof Button>) {\n return (\n <Button\n data-slot=\"attachment-action\"\n variant={variant ?? \"ghost\"}\n size={size}\n className={cn(className)}\n {...props}\n />\n );\n}\n\nfunction AttachmentTrigger({\n className,\n render,\n type,\n ...props\n}: useRender.ComponentProps<\"button\">) {\n return useRender({\n defaultTagName: \"button\",\n props: mergeProps<\"button\">(\n {\n type: render ? type : (type ?? \"button\"),\n className: cn(\"absolute inset-0 z-10 outline-none\", className),\n },\n props\n ),\n render,\n state: {\n slot: \"attachment-trigger\",\n },\n });\n}\n\nfunction AttachmentGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"attachment-group\"\n className={cn(\n \"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport {\n Attachment,\n AttachmentGroup,\n AttachmentMedia,\n AttachmentContent,\n AttachmentTitle,\n AttachmentDescription,\n AttachmentActions,\n AttachmentAction,\n AttachmentTrigger,\n};\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/attachment.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "auth-email-verification",
|
|
4
|
+
"title": "Auth: Email Verification",
|
|
5
|
+
"description": "Post-signup email verification status page with a resend control, backed by the Better-Auth client SDK's sendVerificationEmail action. Reads the ?error=/?email= search params Better-Auth's own /verify-email link handler redirects back with. Requires the auth-login item (provides the shared AuthCard shell); install it first.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"next-intl"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"alert",
|
|
13
|
+
"@intelligo/button",
|
|
14
|
+
"@intelligo/input",
|
|
15
|
+
"@intelligo/spinner",
|
|
16
|
+
"field"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "base/auth-email-verification/page.tsx",
|
|
21
|
+
"content": "import type { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { getAuthSession } from \"@intelligo-dev/auth\";\n\nimport { Link } from \"@/i18n/navigation\";\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { ResendVerificationButton } from \"@/components/auth/resend-verification-button\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-email-verification\");\n return {\n title: t(\"metadata.title\"),\n description: t(\"metadata.description\"),\n };\n}\n\ninterface Props {\n searchParams: Promise<{ error?: string; email?: string }>;\n}\n\n/**\n * Better-Auth's `/verify-email` link handler (clicked from the emailed\n * link, not called from this page) redirects back here with `?error=CODE`\n * on an invalid/expired/unknown token, or with no query param at all —\n * plus a fresh session cookie — on success. That gives this page three\n * honestly distinguishable states without guessing: verified (a session\n * exists), failed (an `error` code came back), or still pending (neither\n * — the visitor hasn't clicked the link yet).\n */\nfunction errorMessageFor(\n error: string | undefined,\n t: Awaited<ReturnType<typeof getTranslations>>\n): string | null {\n if (!error) return null;\n const knownCodes = [\"INVALID_TOKEN\", \"TOKEN_EXPIRED\", \"USER_NOT_FOUND\"];\n const key = knownCodes.includes(error) ? error : \"UNKNOWN\";\n return t(`errors.${key}`);\n}\n\nexport default async function VerifyEmailPage({ searchParams }: Props) {\n const { error, email } = await searchParams;\n const authSession = await getAuthSession();\n const t = await getTranslations(\"auth-email-verification\");\n\n if (authSession?.user.emailVerified) {\n return (\n <AuthCard\n title={t(\"verifiedState.title\")}\n description={t(\"verifiedState.description\")}\n >\n <div className=\"space-y-4\">\n <Alert>\n <AlertDescription>{t(\"verifiedState.message\")}</AlertDescription>\n </Alert>\n <Link\n href=\"/dashboard\"\n className=\"block text-center text-sm font-medium text-primary hover:text-primary/80\"\n >\n {t(\"verifiedState.dashboardLink\")}\n </Link>\n </div>\n </AuthCard>\n );\n }\n\n const errorMessage = errorMessageFor(error, t);\n\n return (\n <AuthCard\n title={errorMessage ? t(\"failedState.title\") : t(\"pendingState.title\")}\n description={errorMessage ?? t(\"pendingState.description\")}\n >\n <div className=\"space-y-4\">\n {errorMessage && (\n <Alert variant=\"destructive\">\n <AlertDescription>{errorMessage}</AlertDescription>\n </Alert>\n )}\n\n <ResendVerificationButton email={email} />\n\n <div className=\"text-center\">\n <Link\n href=\"/login\"\n className=\"text-sm text-muted-foreground hover:text-primary\"\n >\n {t(\"backToLogin\")}\n </Link>\n </div>\n </div>\n </AuthCard>\n );\n}\n",
|
|
22
|
+
"type": "registry:page",
|
|
23
|
+
"target": "app/[locale]/(auth)/verify-email/page.tsx"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "base/auth-email-verification/components/resend-verification-button.tsx",
|
|
27
|
+
"content": "\"use client\";\n\n/**\n * Resends a verification email through Better-Auth's\n * `sendVerificationEmail`. The endpoint works with or without a session\n * and answers the same whether or not the address is registered. When\n * `email` is passed (e.g. from the signup redirect's `?email=`) this\n * renders just the button; otherwise it asks for the address first.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\ninterface ResendVerificationButtonProps {\n email?: string;\n}\n\nexport function ResendVerificationButton({\n email: knownEmail,\n}: ResendVerificationButtonProps) {\n const t = useTranslations(\"auth-email-verification\");\n const [email, setEmail] = useState(knownEmail ?? \"\");\n const [isResending, setIsResending] = useState(false);\n const [resendSuccess, setResendSuccess] = useState(false);\n const [resendError, setResendError] = useState<string | null>(null);\n\n async function handleResend() {\n if (!email) {\n setResendError(t(\"resend.errors.missingEmail\"));\n return;\n }\n\n try {\n setIsResending(true);\n setResendError(null);\n setResendSuccess(false);\n\n const result = await authClient.sendVerificationEmail({\n email,\n callbackURL: \"/verify-email\",\n });\n\n if (result.error) {\n setResendError(result.error.message ?? t(\"resend.errors.unexpected\"));\n return;\n }\n\n setResendSuccess(true);\n } catch (error) {\n console.error(\"Resend verification error:\", error);\n setResendError(t(\"resend.errors.unexpected\"));\n } finally {\n setIsResending(false);\n }\n }\n\n return (\n <div className=\"space-y-4\">\n {!knownEmail && (\n <Field>\n <FieldLabel htmlFor=\"resend-email\">\n {t(\"resend.emailLabel\")}\n </FieldLabel>\n <Input\n id=\"resend-email\"\n type=\"email\"\n autoComplete=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n disabled={isResending || resendSuccess}\n aria-invalid={resendError ? true : undefined}\n aria-describedby={resendError ? \"resend-email-error\" : undefined}\n />\n </Field>\n )}\n\n {resendSuccess && (\n <Alert>\n <AlertDescription>{t(\"resend.success\")}</AlertDescription>\n </Alert>\n )}\n\n {resendError && (\n <Alert id=\"resend-email-error\" variant=\"destructive\">\n <AlertDescription>{resendError}</AlertDescription>\n </Alert>\n )}\n\n <Button\n type=\"button\"\n variant=\"outline\"\n className=\"w-full\"\n onClick={handleResend}\n disabled={isResending || resendSuccess}\n aria-busy={isResending || undefined}\n >\n {isResending ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"resend.sending\")}\n </>\n ) : resendSuccess ? (\n t(\"resend.sent\")\n ) : (\n t(\"resend.submit\")\n )}\n </Button>\n </div>\n );\n}\n",
|
|
28
|
+
"type": "registry:component",
|
|
29
|
+
"target": "components/auth/resend-verification-button.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "base/auth-email-verification/messages/en.json",
|
|
33
|
+
"content": "{\n \"metadata\": {\n \"title\": \"Verify your email\",\n \"description\": \"Confirm your email address.\"\n },\n \"verifiedState\": {\n \"title\": \"Email verified\",\n \"description\": \"Your email address has been confirmed\",\n \"message\": \"You're all set — your account is fully active.\",\n \"dashboardLink\": \"Go to dashboard\"\n },\n \"pendingState\": {\n \"title\": \"Verify your email\",\n \"description\": \"We've sent a verification link to your email. Click it to activate your account.\"\n },\n \"failedState\": {\n \"title\": \"Verification failed\"\n },\n \"backToLogin\": \"Back to login\",\n \"errors\": {\n \"INVALID_TOKEN\": \"This verification link is invalid. Request a new one below.\",\n \"TOKEN_EXPIRED\": \"This verification link has expired. Request a new one below.\",\n \"USER_NOT_FOUND\": \"We couldn't find an account for this verification link.\",\n \"UNKNOWN\": \"This verification link could not be used. Request a new one below.\"\n },\n \"resend\": {\n \"emailLabel\": \"Email\",\n \"submit\": \"Resend verification email\",\n \"sending\": \"Sending…\",\n \"sent\": \"Verification email sent\",\n \"success\": \"Verification email sent. Check your inbox.\",\n \"errors\": {\n \"missingEmail\": \"Enter your email address first.\",\n \"unexpected\": \"Failed to send verification email.\"\n }\n }\n}\n",
|
|
34
|
+
"type": "registry:file",
|
|
35
|
+
"target": "messages/en/auth-email-verification.json"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"type": "registry:block"
|
|
39
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "auth-login",
|
|
4
|
+
"title": "Auth: Login",
|
|
5
|
+
"description": "Email/password and OAuth login. Ships the shared auth route layout, the de-branded AuthCard shell, and the auth-validation.ts zod schemas reused by auth-signup, auth-password-reset, and auth-email-verification - install this item first.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@intelligo-dev/auth",
|
|
8
|
+
"react-hook-form@^7.71.1",
|
|
9
|
+
"@hookform/resolvers@^5.2.2",
|
|
10
|
+
"zod@^4.6.5",
|
|
11
|
+
"lucide-react",
|
|
12
|
+
"next-intl"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [
|
|
15
|
+
"alert",
|
|
16
|
+
"@intelligo/button",
|
|
17
|
+
"@intelligo/input",
|
|
18
|
+
"@intelligo/spinner",
|
|
19
|
+
"field"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": "base/auth-login/layout.tsx",
|
|
24
|
+
"content": "/**\n * Minimal centered layout shared by every auth-* route: no nav, no\n * sidebar, just the page content inside the `main` landmark, behind a\n * skip link that is visible only when focused. Each auth-* page\n * composes its own `AuthCard` inside this shell.\n */\n\nimport { getTranslations } from \"next-intl/server\";\nimport type { ReactNode } from \"react\";\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 AuthLayout({\n children,\n}: {\n children: ReactNode;\n}) {\n const t = await getTranslations(\"auth-login\");\n return (\n <>\n <a href=\"#main-content\" className={SKIP_LINK_CLASS}>\n {t(\"skipToContent\")}\n </a>\n <main\n id=\"main-content\"\n tabIndex={-1}\n className=\"min-h-screen bg-background outline-none\"\n >\n {children}\n </main>\n </>\n );\n}\n",
|
|
25
|
+
"type": "registry:page",
|
|
26
|
+
"target": "app/[locale]/(auth)/layout.tsx"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "base/auth-login/page.tsx",
|
|
30
|
+
"content": "import type { Metadata } from \"next\";\nimport { getLocale, getTranslations } from \"next-intl/server\";\n\nimport { getAuthSession } from \"@intelligo-dev/auth\";\n\nimport { redirect } from \"@/i18n/navigation\";\nimport { returnPath } from \"@/lib/auth-validation\";\nimport { AuthCard } from \"@/components/auth/auth-card\";\nimport { SocialLoginButtons } from \"@/components/auth/social-login-buttons\";\nimport { LoginForm } from \"@/components/auth/login-form\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"auth-login\");\n return {\n title: t(\"metadata.title\"),\n description: t(\"metadata.description\"),\n };\n}\n\n/**\n * A signed-in visitor has no use for this page.\n *\n * Done here rather than in middleware because middleware only sees the\n * session cookie, not the session. Redirecting on the cookie while the\n * authenticated app layout redirects on the real session is an infinite\n * loop for anyone holding a stale one — revoked elsewhere, expired row,\n * rotated secret — and the loop locks them out of the page that would\n * fix it.\n */\nasync function redirectIfSignedIn(next: string): Promise<void> {\n if (await getAuthSession())\n redirect({ href: next, locale: await getLocale() });\n}\n\nexport default async function LoginPage({\n searchParams,\n}: {\n searchParams: Promise<{ next?: string | string[] }>;\n}) {\n // Where the reader was going — an invitation link, say — when they\n // were sent here to sign in.\n const raw = (await searchParams).next;\n const next = returnPath(Array.isArray(raw) ? raw[0] : raw);\n await redirectIfSignedIn(next);\n\n const t = await getTranslations(\"auth-login\");\n\n // OAuth provider buttons only render for providers actually configured\n // on the server — checked here, not in the client component, so an\n // unconfigured client id/secret never reaches the browser at all.\n const providers: string[] = [];\n if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {\n providers.push(\"google\");\n }\n if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {\n providers.push(\"github\");\n }\n\n return (\n <AuthCard title={t(\"card.title\")} description={t(\"card.description\")}>\n <div className=\"space-y-6\">\n <SocialLoginButtons providers={providers} next={next} />\n <LoginForm next={next} />\n </div>\n </AuthCard>\n );\n}\n",
|
|
31
|
+
"type": "registry:page",
|
|
32
|
+
"target": "app/[locale]/(auth)/login/page.tsx"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "base/auth-login/components/auth-card.tsx",
|
|
36
|
+
"content": "/**\n * Shared auth page shell, unbranded.\n *\n * Ships with `auth-login`; `auth-signup`, `auth-password-reset`, and\n * `auth-email-verification` all import it from `@/components/auth/auth-card`\n * once installed (see those items' descriptions for the cross-item\n * dependency). A consumer that wants a logo or product name above the\n * card composes it around this component (or in `app/(auth)/layout.tsx`)\n * rather than editing this file — keeping it unbranded is what lets every\n * auth-* item reuse it as-is.\n */\n\nimport type { ReactNode } from \"react\";\n\ninterface AuthCardProps {\n title: string;\n description?: string;\n children: ReactNode;\n footer?: ReactNode;\n}\n\nexport function AuthCard({\n title,\n description,\n children,\n footer,\n}: AuthCardProps) {\n return (\n <div className=\"flex min-h-screen flex-col items-center justify-center px-4 py-12\">\n <div className=\"w-full max-w-md\">\n <div className=\"rounded-xl border border-border bg-card p-8 shadow-sm\">\n <div className=\"mb-6 text-center\">\n <h1 className=\"text-2xl font-bold text-foreground\">{title}</h1>\n {description && (\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {description}\n </p>\n )}\n </div>\n {children}\n {footer && <div className=\"mt-6\">{footer}</div>}\n </div>\n </div>\n </div>\n );\n}\n",
|
|
37
|
+
"type": "registry:component",
|
|
38
|
+
"target": "components/auth/auth-card.tsx"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "base/auth-login/components/login-form.tsx",
|
|
42
|
+
"content": "\"use client\";\n\n/**\n * Email + password login form. Calls the Better-Auth client SDK\n * (`@intelligo-dev/auth/client`) directly — no raw `fetch(\"/api/auth/*\")`.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Link, useRouter } from \"@/i18n/navigation\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { loginSchema, type LoginInput } from \"@/lib/auth-validation\";\n\n/** `next`: where to go once signed in — a path on this site. */\nexport function LoginForm({ next = \"/dashboard\" }: { next?: string }) {\n const t = useTranslations(\"auth-login\");\n const router = useRouter();\n const [formError, setFormError] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n\n const {\n register,\n handleSubmit,\n formState: { errors },\n } = useForm<LoginInput>({\n resolver: zodResolver(loginSchema),\n });\n\n async function onSubmit(data: LoginInput) {\n try {\n setFormError(null);\n setIsLoading(true);\n\n const result = await authClient.signIn.email({\n email: data.email,\n password: data.password,\n callbackURL: next,\n });\n\n if (result.error) {\n setFormError(\n result.error.message ?? t(\"loginForm.errors.invalidCredentials\")\n );\n setIsLoading(false);\n return;\n }\n\n router.push(next);\n } catch (error) {\n console.error(\"Login error:\", error);\n setFormError(t(\"loginForm.errors.unexpected\"));\n setIsLoading(false);\n }\n }\n\n return (\n <form onSubmit={handleSubmit(onSubmit)} noValidate className=\"space-y-4\">\n {formError && (\n <Alert variant=\"destructive\">\n <AlertDescription>{formError}</AlertDescription>\n </Alert>\n )}\n\n <Field data-invalid={errors.email ? true : undefined}>\n <FieldLabel htmlFor=\"email\">{t(\"loginForm.emailLabel\")}</FieldLabel>\n <Input\n id=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n disabled={isLoading}\n aria-invalid={errors.email ? true : undefined}\n aria-describedby={errors.email ? \"email-error\" : undefined}\n {...register(\"email\")}\n />\n {errors.email && (\n <FieldError id=\"email-error\">\n {t(`validation.${errors.email.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Field data-invalid={errors.password ? true : undefined}>\n <div className=\"flex items-center justify-between\">\n <FieldLabel htmlFor=\"password\">\n {t(\"loginForm.passwordLabel\")}\n </FieldLabel>\n <Link\n href=\"/forgot-password\"\n className=\"text-xs font-medium text-primary hover:text-primary/80\"\n >\n {t(\"loginForm.forgotPassword\")}\n </Link>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n autoComplete=\"current-password\"\n disabled={isLoading}\n aria-invalid={errors.password ? true : undefined}\n aria-describedby={errors.password ? \"password-error\" : undefined}\n {...register(\"password\")}\n />\n {errors.password && (\n <FieldError id=\"password-error\">\n {t(`validation.${errors.password.message}`)}\n </FieldError>\n )}\n </Field>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n disabled={isLoading}\n aria-busy={isLoading || undefined}\n >\n {isLoading ? (\n <>\n <Spinner data-icon=\"inline-start\" />\n {t(\"loginForm.submitting\")}\n </>\n ) : (\n t(\"loginForm.submit\")\n )}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t(\"loginForm.noAccount\")}{\" \"}\n <Link\n href={\n next === \"/dashboard\"\n ? \"/signup\"\n : `/signup?next=${encodeURIComponent(next)}`\n }\n className=\"font-medium text-primary hover:text-primary/80\"\n >\n {t(\"loginForm.signUpLink\")}\n </Link>\n </p>\n </form>\n );\n}\n",
|
|
43
|
+
"type": "registry:component",
|
|
44
|
+
"target": "components/auth/login-form.tsx"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "base/auth-login/components/social-login-buttons.tsx",
|
|
48
|
+
"content": "\"use client\";\n\n/**\n * OAuth provider buttons — renders only the providers the server page\n * passes in via `providers` (see `page.tsx`: the env-var check is\n * server-side, so a provider with no client id/secret configured never\n * reaches the client at all). Calls the Better-Auth client SDK directly;\n * no raw `fetch(\"/api/auth/*\")`.\n *\n * Ships with `auth-login`; `auth-signup` reuses this same file from\n * `@/components/auth/social-login-buttons` once installed, so its copy\n * text lives under the `auth-login` message namespace regardless of\n * which page renders it — see that item's description for the\n * cross-item dependency.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { authClient } from \"@intelligo-dev/auth/client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\ntype Provider = \"google\" | \"github\";\n\ninterface SocialLoginButtonsProps {\n providers: string[];\n /** Where to go once signed in — a path on this site. */\n next?: string;\n}\n\nexport function SocialLoginButtons({\n providers,\n next = \"/dashboard\",\n}: SocialLoginButtonsProps) {\n const t = useTranslations(\"auth-login\");\n const [loadingProvider, setLoadingProvider] = useState<Provider | null>(null);\n const [failed, setFailed] = useState(false);\n\n if (providers.length === 0) {\n return null;\n }\n\n async function handleSocialLogin(provider: Provider) {\n setFailed(false);\n setLoadingProvider(provider);\n try {\n // On success the browser leaves for the provider. A provider the\n // server does not have configured answers `{ error }` instead of\n // throwing, and the buttons must come back either way.\n const { error } = await authClient.signIn.social({\n provider,\n callbackURL: next,\n });\n if (!error) return;\n } catch {\n // Same outcome as `{ error }`: stay on the page and say so.\n }\n setFailed(true);\n setLoadingProvider(null);\n }\n\n return (\n <div className=\"space-y-4\">\n {failed ? (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {t(\"socialLogin.failed\")}\n </p>\n ) : null}\n <div className=\"grid gap-2\">\n {providers.includes(\"google\") && (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => handleSocialLogin(\"google\")}\n disabled={loadingProvider !== null}\n className=\"w-full\"\n >\n {loadingProvider === \"google\" ? (\n <Spinner />\n ) : (\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 18 18\"\n fill=\"none\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z\"\n fill=\"#4285F4\"\n />\n <path\n d=\"M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z\"\n fill=\"#34A853\"\n />\n <path\n d=\"M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332Z\"\n fill=\"#FBBC05\"\n />\n <path\n d=\"M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 6.29C4.672 4.163 6.656 3.58 9 3.58Z\"\n fill=\"#EA4335\"\n />\n </svg>\n )}\n {loadingProvider === \"google\"\n ? t(\"socialLogin.connecting\")\n : t(\"socialLogin.continueWithGoogle\")}\n </Button>\n )}\n\n {providers.includes(\"github\") && (\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => handleSocialLogin(\"github\")}\n disabled={loadingProvider !== null}\n className=\"w-full\"\n >\n {loadingProvider === \"github\" ? (\n <Spinner />\n ) : (\n <svg\n className=\"size-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 24 24\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n </svg>\n )}\n {loadingProvider === \"github\"\n ? t(\"socialLogin.connecting\")\n : t(\"socialLogin.continueWithGithub\")}\n </Button>\n )}\n </div>\n\n <div className=\"flex items-center gap-4\">\n <div className=\"h-px flex-1 bg-border\" />\n <span className=\"text-xs font-medium uppercase tracking-wider text-muted-foreground\">\n {t(\"socialLogin.orContinueWith\")}\n </span>\n <div className=\"h-px flex-1 bg-border\" />\n </div>\n </div>\n );\n}\n",
|
|
49
|
+
"type": "registry:component",
|
|
50
|
+
"target": "components/auth/social-login-buttons.tsx"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "base/auth-login/lib/auth-validation.ts",
|
|
54
|
+
"content": "/**\n * Shared client-side validation schemas for the auth-* registry items.\n *\n * Ships with `auth-login`; `auth-signup` and `auth-password-reset` import\n * it from `@/lib/auth-validation` once installed (see those items'\n * descriptions for the cross-item dependency). Plain zod, no\n * `@intelligo-dev/*` imports.\n *\n * Validation copy is user-facing, so these schemas do not carry literal\n * English strings — they carry stable error CODES (plain identifiers, not\n * translated text), and the form component that owns each schema maps a\n * code to `t(\\`validation.${code}\\`)` under its own registry item's\n * message namespace. A schema file has no component tree to\n * call `useTranslations`/`getTranslations` from, so translation happens\n * one layer up, in the form.\n */\n\nimport { z } from \"zod\";\n\n/** At least one uppercase letter, one lowercase letter, and one digit. */\nconst passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$/;\n\nexport const loginSchema = z.object({\n email: z.string().email(\"emailInvalid\"),\n password: z.string().min(8, \"passwordTooShort\"),\n});\n\nexport type LoginInput = z.infer<typeof loginSchema>;\n\nexport const signupSchema = z\n .object({\n name: z.string().min(2, \"nameTooShort\").max(50, \"nameTooLong\"),\n email: z.string().email(\"emailInvalid\"),\n password: z\n .string()\n .min(8, \"passwordTooShort\")\n .max(128, \"passwordTooLong\")\n .regex(passwordRegex, \"passwordWeak\"),\n confirmPassword: z.string(),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: \"passwordMismatch\",\n path: [\"confirmPassword\"],\n });\n\nexport type SignupInput = z.infer<typeof signupSchema>;\n\nexport const forgotPasswordSchema = z.object({\n email: z.string().email(\"emailInvalid\"),\n});\n\nexport type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;\n\nexport const resetPasswordSchema = z\n .object({\n password: z\n .string()\n .min(8, \"passwordTooShort\")\n .max(128, \"passwordTooLong\")\n .regex(passwordRegex, \"passwordWeak\"),\n confirmPassword: z.string(),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: \"passwordMismatch\",\n path: [\"confirmPassword\"],\n });\n\nexport type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;\n\n/**\n * Where to go after signing in: `next` when it is a path on this site,\n * `/dashboard` otherwise. A full URL, or `//host`, would let a link send\n * a reader who just signed in to somebody else's site.\n */\nexport function returnPath(next: string | null | undefined): string {\n return typeof next === \"string\" && /^\\/(?![/\\\\])/.test(next)\n ? next\n : \"/dashboard\";\n}\n",
|
|
55
|
+
"type": "registry:file",
|
|
56
|
+
"target": "lib/auth-validation.ts"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "base/auth-login/messages/en.json",
|
|
60
|
+
"content": "{\n \"skipToContent\": \"Skip to content\",\n \"metadata\": {\n \"title\": \"Log in\",\n \"description\": \"Log in to your account.\"\n },\n \"card\": {\n \"title\": \"Welcome back\",\n \"description\": \"Log in to your account\"\n },\n \"loginForm\": {\n \"emailLabel\": \"Email\",\n \"passwordLabel\": \"Password\",\n \"forgotPassword\": \"Forgot password?\",\n \"submit\": \"Sign in\",\n \"submitting\": \"Signing in…\",\n \"noAccount\": \"Don't have an account?\",\n \"signUpLink\": \"Sign up\",\n \"errors\": {\n \"invalidCredentials\": \"Incorrect email or password.\",\n \"unexpected\": \"Something went wrong. Please try again.\"\n }\n },\n \"socialLogin\": {\n \"connecting\": \"Connecting…\",\n \"continueWithGoogle\": \"Continue with Google\",\n \"continueWithGithub\": \"Continue with GitHub\",\n \"orContinueWith\": \"Or continue with\",\n \"failed\": \"Couldn't start that sign-in. Please try again or use your email.\"\n },\n \"validation\": {\n \"emailInvalid\": \"Please enter a valid email address\",\n \"passwordTooShort\": \"Password must be at least 8 characters\"\n }\n}\n",
|
|
61
|
+
"type": "registry:file",
|
|
62
|
+
"target": "messages/en/auth-login.json"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "base/auth-login/auth-error.tsx",
|
|
66
|
+
"content": "\"use client\";\n\n/**\n * Error boundary for the auth route group, which renders outside the\n * app shell. Renders the shared `RouteError` from the `route-error`\n * 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 (\n <RouteError\n error={error}\n reset={reset}\n scope=\"auth\"\n fullScreen\n homeHref=\"/login\"\n homeKey=\"goToLogin\"\n />\n );\n}\n",
|
|
67
|
+
"type": "registry:page",
|
|
68
|
+
"target": "app/[locale]/(auth)/error.tsx"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"type": "registry:block"
|
|
72
|
+
}
|