chore: starter template
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
.wrangler
|
||||||
|
|
||||||
|
# All *.gen.* files are regenerated by `pikku all` — never commit them.
|
||||||
|
*.gen.ts
|
||||||
|
*.gen.js
|
||||||
|
*.gen.json
|
||||||
|
*.gen.mjs
|
||||||
|
|
||||||
|
# Yarn — commit the root lockfile; ignore PnP/cache and generated per-unit lockfiles
|
||||||
|
.yarn/
|
||||||
|
.pnp.*
|
||||||
|
yarn.lock
|
||||||
|
!/yarn.lock
|
||||||
|
|
||||||
|
# Injected/symlinked at sandbox runtime — never commit
|
||||||
|
vendor/
|
||||||
|
.opencode
|
||||||
|
.pikku-runtime
|
||||||
|
__fabric_scaffold.vite.config.mjs
|
||||||
4
.yarnrc.yml
Normal file
4
.yarnrc.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Force node_modules linker — some toolchain deps (uWebSockets.js native
|
||||||
|
# addon, pikku CLI bin, esbuild native) resolve cleanly only with a
|
||||||
|
# traditional tree.
|
||||||
|
nodeLinker: node-modules
|
||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Building in this project
|
||||||
|
|
||||||
|
This app ships a **component kit** at `@project/components` (source in
|
||||||
|
`packages/components/src`). **Compose these instead of hand-rolling UI** — it is faster,
|
||||||
|
consistent, and already themed. Import from `@project/components`.
|
||||||
|
|
||||||
|
## Available components
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
- `PageHeader` — page title + optional description + right-aligned `actions` slot. Use one per page.
|
||||||
|
- `Panel` — bordered section with an optional titled header (`title`, `description`, `actions`). The workhorse container.
|
||||||
|
- `StatCard` — a single metric tile (`label`, `value`, optional `icon`, `color`).
|
||||||
|
- `StatGrid` — responsive row of `StatCard`s; pass `stats={[{label, value, icon}]}`.
|
||||||
|
|
||||||
|
Data (feed these the output of an RPC you implement):
|
||||||
|
- `DataTable` — generic typed table. Props: `columns` (`{key, header, render?, align?}`), `rows`, `rowKey`, optional `loading`/`empty`/`onRowClick`. Implement a `listX` RPC and pass its rows.
|
||||||
|
- `BarChart` — dependency-free horizontal bars from `data={[{label, value, color?}]}`. Good for status/count breakdowns from a stats RPC.
|
||||||
|
|
||||||
|
State (already used by the app):
|
||||||
|
- `EmptyState`, `PageLoader`, `NotFoundState`, `ServerErrorState`, `UserCard`.
|
||||||
|
|
||||||
|
## How to build a feature page
|
||||||
|
|
||||||
|
1. Implement the backend RPCs (`pikku-rpc` / `pikku-kysely` skills): a `listX` and any mutations, with zod input/output and `auth: true`. Scope rows to the signed-in user (`where('userId','=',session.userId)`).
|
||||||
|
2. Fetch with `usePikkuQuery('listX', {})` and mutate with `usePikkuMutation('createX')` (from `@project/functions-sdk/pikku/api.gen`).
|
||||||
|
3. Build the page by composing the kit: `PageHeader` + `Panel` + `DataTable`/`StatGrid`/`BarChart`. Pass i18n strings (`m.key()`) into the component props.
|
||||||
|
|
||||||
|
These are props-only components — they never fetch data themselves. You own the page that wires RPC data into them.
|
||||||
42
apps/app/messages/en.json
Normal file
42
apps/app/messages/en.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"error__title": "This page isn't running properly",
|
||||||
|
"error__hint": "If an agent is building this app, just wait — it's likely mid-change and will fix this shortly, and the page will refresh on its own.",
|
||||||
|
"error__retry": "Try again",
|
||||||
|
"error__details": "Error details",
|
||||||
|
"common__loading": "Loading…",
|
||||||
|
"common__email": "Email",
|
||||||
|
"common__email_placeholder": "you@example.com",
|
||||||
|
"common__password": "Password",
|
||||||
|
"common__password_placeholder": "••••••••",
|
||||||
|
"common__name": "Name",
|
||||||
|
"common__name_placeholder": "Ada Lovelace",
|
||||||
|
"validation__required": "Required.",
|
||||||
|
"validation__email_invalid": "Enter a valid email address.",
|
||||||
|
"app__name": "Fabric Starter",
|
||||||
|
"auth__or": "or",
|
||||||
|
"auth__continue_with_google": "Continue with Google",
|
||||||
|
"auth__google_error": "Unable to continue with Google right now.",
|
||||||
|
"auth__login__title": "Sign in",
|
||||||
|
"auth__login__description": "Welcome back to {name}.",
|
||||||
|
"auth__login__cta": "Sign in",
|
||||||
|
"auth__login__invalid_credentials": "That email and password don't match.",
|
||||||
|
"auth__login__error": "Unable to sign in right now.",
|
||||||
|
"auth__login__footer_prompt": "Don't have an account?",
|
||||||
|
"auth__login__footer_action": "Sign up",
|
||||||
|
"auth__dev_quick_login": "Quick login (dev)",
|
||||||
|
"auth__dev_quick_login_hint": "Development only — signs in as test@example.com",
|
||||||
|
"auth__signup__title": "Create your account",
|
||||||
|
"auth__signup__description": "Get started with {name}.",
|
||||||
|
"auth__signup__cta": "Create account",
|
||||||
|
"auth__signup__email_in_use": "An account with this email already exists.",
|
||||||
|
"auth__signup__error": "Unable to create your account right now.",
|
||||||
|
"auth__signup__footer_prompt": "Already have an account?",
|
||||||
|
"auth__signup__footer_action": "Sign in",
|
||||||
|
"nav__home": "Home",
|
||||||
|
"app_shell__sign_out": "Sign out",
|
||||||
|
"home__title": "Hello, world.",
|
||||||
|
"home__body": "You're signed in to {name}. This is the bare-bones starter every project begins from — describe what you want to build and it gets reshaped from here.",
|
||||||
|
"preferences__language": "Language",
|
||||||
|
"preferences__theme": "Theme"
|
||||||
|
}
|
||||||
36
apps/app/messages/fr.json
Normal file
36
apps/app/messages/fr.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"common__loading": "Chargement…",
|
||||||
|
"common__email": "Email",
|
||||||
|
"common__email_placeholder": "vous@exemple.com",
|
||||||
|
"common__password": "Mot de passe",
|
||||||
|
"common__password_placeholder": "••••••••",
|
||||||
|
"common__name": "Nom",
|
||||||
|
"common__name_placeholder": "Ada Lovelace",
|
||||||
|
"validation__required": "Requis.",
|
||||||
|
"validation__email_invalid": "Saisissez une adresse email valide.",
|
||||||
|
"app__name": "Fabric Starter",
|
||||||
|
"auth__or": "ou",
|
||||||
|
"auth__continue_with_google": "Continuer avec Google",
|
||||||
|
"auth__google_error": "Impossible de continuer avec Google pour l'instant.",
|
||||||
|
"auth__login__title": "Se connecter",
|
||||||
|
"auth__login__description": "Bon retour sur {name}.",
|
||||||
|
"auth__login__cta": "Se connecter",
|
||||||
|
"auth__login__invalid_credentials": "Cet email et ce mot de passe ne correspondent pas.",
|
||||||
|
"auth__login__error": "Impossible de se connecter pour l'instant.",
|
||||||
|
"auth__login__footer_prompt": "Pas encore de compte ?",
|
||||||
|
"auth__login__footer_action": "S'inscrire",
|
||||||
|
"auth__signup__title": "Créez votre compte",
|
||||||
|
"auth__signup__description": "Commencez avec {name}.",
|
||||||
|
"auth__signup__cta": "Créer un compte",
|
||||||
|
"auth__signup__email_in_use": "Un compte avec cet email existe déjà.",
|
||||||
|
"auth__signup__error": "Impossible de créer votre compte pour l'instant.",
|
||||||
|
"auth__signup__footer_prompt": "Vous avez déjà un compte ?",
|
||||||
|
"auth__signup__footer_action": "Se connecter",
|
||||||
|
"nav__home": "Accueil",
|
||||||
|
"app_shell__sign_out": "Se déconnecter",
|
||||||
|
"home__title": "Bonjour, monde.",
|
||||||
|
"home__body": "Vous êtes connecté à {name}. C'est le point de départ minimal de chaque projet — décrivez ce que vous voulez construire et tout se reconstruit à partir d'ici.",
|
||||||
|
"preferences__language": "Langue",
|
||||||
|
"preferences__theme": "Thème"
|
||||||
|
}
|
||||||
42
apps/app/package.json
Normal file
42
apps/app/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@project/app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 7104",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 7104",
|
||||||
|
"tsc": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/app-tsc.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@assistant-ui/react": "^0.14.24",
|
||||||
|
"@mantine/core": "^9.2.1",
|
||||||
|
"@mantine/hooks": "^9.2.1",
|
||||||
|
"@pikku/assistant-ui": "^0.12.7",
|
||||||
|
"@pikku/mantine": "^0.12.6",
|
||||||
|
"@pikku/react": "^0.12.5",
|
||||||
|
"@project/components": "workspace:*",
|
||||||
|
"@project/functions-sdk": "workspace:*",
|
||||||
|
"@project/mantine-themes": "workspace:*",
|
||||||
|
"@tanstack/react-form": "^1.0.0",
|
||||||
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@tanstack/react-router": "^1.132.0",
|
||||||
|
"@tanstack/react-start": "^1.132.0",
|
||||||
|
"better-auth": "^1.6.18",
|
||||||
|
"lucide-react": "^0.456.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.0",
|
||||||
|
"@inlang/paraglide-js": "^2.20.0",
|
||||||
|
"@tanstack/router-generator": "^1.132.0",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"typescript": "^5.9",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/app/project.inlang/settings.json
Normal file
9
apps/app/project.inlang/settings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/project-settings",
|
||||||
|
"baseLocale": "en",
|
||||||
|
"locales": ["en", "fr"],
|
||||||
|
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"],
|
||||||
|
"plugin.inlang.messageFormat": {
|
||||||
|
"pathPattern": "./messages/{locale}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/app/src/components/AppShell.tsx
Normal file
90
apps/app/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AppShell as MantineAppShell, Box, Button, NavLink, Stack } from '@pikku/mantine/core'
|
||||||
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useState, type FC } from 'react'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { signOut } from '@/lib/auth'
|
||||||
|
import { Wordmark } from './Wordmark'
|
||||||
|
import { LanguageSelector } from './LanguageSelector'
|
||||||
|
import { ThemeSelector } from './ThemeSelector'
|
||||||
|
|
||||||
|
const HomeGlyph = () => <NavGlyph d="M3 12 12 4l9 8M5 10v9h5v-6h4v6h5v-9" />
|
||||||
|
const SignOutGlyph = () => (
|
||||||
|
<NavGlyph d="M16 17l5-5-5-5M21 12H9M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavGlyph({ d }: { d: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d={d} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppShell: FC = () => {
|
||||||
|
useLocale()
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineAppShell navbar={{ width: 236, breakpoint: 'sm' }} padding="xl">
|
||||||
|
<MantineAppShell.Navbar p="md">
|
||||||
|
<Stack h="100%" gap={4}>
|
||||||
|
<Box px="xs" py="sm">
|
||||||
|
<Wordmark name={m.app__name()} size={26} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack gap={2} mt="xs">
|
||||||
|
<NavLink
|
||||||
|
component={Link}
|
||||||
|
to="/app"
|
||||||
|
label={m.nav__home()}
|
||||||
|
leftSection={<HomeGlyph />}
|
||||||
|
active={pathname === '/app'}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs" mt="auto">
|
||||||
|
<ThemeSelector />
|
||||||
|
<LanguageSelector />
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
fullWidth
|
||||||
|
justify="flex-start"
|
||||||
|
leftSection={<SignOutGlyph />}
|
||||||
|
loading={isSigningOut}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsSigningOut(true)
|
||||||
|
try {
|
||||||
|
await signOut()
|
||||||
|
window.location.href = '/login'
|
||||||
|
} finally {
|
||||||
|
setIsSigningOut(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.app_shell__sign_out()}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</MantineAppShell.Navbar>
|
||||||
|
|
||||||
|
{/* Flex column so a full-height page (e.g. a chat) can fill the remaining
|
||||||
|
viewport with just `flex: 1, minHeight: 0` — no viewport math needed.
|
||||||
|
Content-sized pages are unaffected. */}
|
||||||
|
<MantineAppShell.Main style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Outlet />
|
||||||
|
</MantineAppShell.Main>
|
||||||
|
</MantineAppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
apps/app/src/components/AuthCard.tsx
Normal file
227
apps/app/src/components/AuthCard.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import type { I18nNode, I18nString } from '@pikku/react'
|
||||||
|
import { useForm } from '@tanstack/react-form'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from '@pikku/mantine/core'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { Wordmark } from './Wordmark'
|
||||||
|
|
||||||
|
export type AuthFormValues = { name: string; email: string; password: string }
|
||||||
|
|
||||||
|
type AuthCardProps = {
|
||||||
|
appName: I18nString
|
||||||
|
title: I18nString
|
||||||
|
description: I18nString
|
||||||
|
cta: I18nString
|
||||||
|
// Show the Name field (signup) above email.
|
||||||
|
includeName?: boolean
|
||||||
|
passwordAutoComplete: 'current-password' | 'new-password'
|
||||||
|
// Server-side error from the page's mutation (bad credentials, email in use…).
|
||||||
|
error: I18nString | null
|
||||||
|
googleBusy: boolean
|
||||||
|
// Submitting state of the page's mutation; drives the primary button spinner.
|
||||||
|
busy: boolean
|
||||||
|
footer: I18nNode
|
||||||
|
// Called with validated values once the TanStack form passes validation.
|
||||||
|
onAuthSubmit: (values: AuthFormValues) => void
|
||||||
|
onGoogle: () => void
|
||||||
|
// Dev-only: prefill the form (e.g. the test account) when provided.
|
||||||
|
initialValues?: Partial<AuthFormValues>
|
||||||
|
// Dev-only: a one-click quick-login action rendered below the form.
|
||||||
|
quickLogin?: { label: I18nString; hint: I18nString; onClick: () => void; busy: boolean } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArrowGlyph = () => (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
<polyline points="12 5 19 12 12 19" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const GoogleGlyph = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.57c2.08-1.92 3.27-4.74 3.27-8.09Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.76c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.22V7.04H2.18a11 11 0 0 0 0 9.92l3.66-2.86Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04L5.84 9.9C6.71 7.3 9.14 5.38 12 5.38Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const required = (value: string): I18nString | undefined =>
|
||||||
|
value.trim() ? undefined : m.validation__required()
|
||||||
|
|
||||||
|
const validEmail = (value: string): I18nString | undefined => {
|
||||||
|
if (!value.trim()) return m.validation__required()
|
||||||
|
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) ? undefined : m.validation__email_invalid()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthCard: FC<AuthCardProps> = (props) => {
|
||||||
|
useLocale()
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { name: '', email: '', password: '', ...props.initialValues } as AuthFormValues,
|
||||||
|
onSubmit: ({ value }) => props.onAuthSubmit(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
mih="100vh"
|
||||||
|
style={{ display: 'grid', placeItems: 'center', background: 'var(--mantine-color-body)' }}
|
||||||
|
p="xl"
|
||||||
|
>
|
||||||
|
<Stack w="100%" maw={380} gap="lg">
|
||||||
|
<Group justify="center">
|
||||||
|
<Wordmark name={props.appName} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder radius="lg" shadow="sm" padding="xl">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Title order={2} fz={21} fw={650} style={{ letterSpacing: '-0.025em' }}>
|
||||||
|
{props.title}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="sm" mt={4}>
|
||||||
|
{props.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void form.handleSubmit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{props.includeName ? (
|
||||||
|
<form.Field name="name" validators={{ onChange: ({ value }) => required(value) }}>
|
||||||
|
{(field) => (
|
||||||
|
<TextInput
|
||||||
|
label={m.common__name()}
|
||||||
|
placeholder={m.common__name_placeholder()}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(event) => field.handleChange(event.currentTarget.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
error={field.state.meta.errors[0] as I18nString | undefined}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form.Field
|
||||||
|
name="email"
|
||||||
|
validators={{ onChange: ({ value }) => validEmail(value) }}
|
||||||
|
>
|
||||||
|
{(field) => (
|
||||||
|
<TextInput
|
||||||
|
label={m.common__email()}
|
||||||
|
placeholder={m.common__email_placeholder()}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(event) => field.handleChange(event.currentTarget.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
error={field.state.meta.errors[0] as I18nString | undefined}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field
|
||||||
|
name="password"
|
||||||
|
validators={{ onChange: ({ value }) => required(value) }}
|
||||||
|
>
|
||||||
|
{(field) => (
|
||||||
|
<PasswordInput
|
||||||
|
label={m.common__password()}
|
||||||
|
placeholder={m.common__password_placeholder()}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(event) => field.handleChange(event.currentTarget.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
error={field.state.meta.errors[0] as I18nString | undefined}
|
||||||
|
autoComplete={props.passwordAutoComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{props.error ? (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{props.error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" loading={props.busy} fullWidth rightSection={<ArrowGlyph />}>
|
||||||
|
{props.cta}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{props.quickLogin ? (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
fullWidth
|
||||||
|
loading={props.quickLogin.busy}
|
||||||
|
onClick={props.quickLogin.onClick}
|
||||||
|
>
|
||||||
|
{props.quickLogin.label}
|
||||||
|
</Button>
|
||||||
|
<Text ta="center" size="xs" c="dimmed">
|
||||||
|
{props.quickLogin.hint}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Divider label={m.auth__or()} labelPosition="center" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
loading={props.googleBusy}
|
||||||
|
leftSection={<GoogleGlyph />}
|
||||||
|
onClick={props.onGoogle}
|
||||||
|
>
|
||||||
|
{m.auth__continue_with_google()}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text ta="center" size="sm" c="dimmed">
|
||||||
|
{props.footer}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
apps/app/src/components/DefaultErrorPage.tsx
Normal file
97
apps/app/src/components/DefaultErrorPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Button, Group, Stack, Text, Title } from '@pikku/mantine/core'
|
||||||
|
import { AlertTriangle, ChevronRight } from 'lucide-react'
|
||||||
|
import { asI18n } from '@pikku/react'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
|
||||||
|
// Router-level error boundary (wired as the router's defaultErrorComponent). In
|
||||||
|
// dev it reveals the raw error in an expandable section — agents watch for this
|
||||||
|
// while iterating — while production stays a calm, generic message.
|
||||||
|
export function DefaultErrorPage({ error }: { error: unknown }) {
|
||||||
|
useLocale()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center" gap="md" style={{ maxWidth: 540, width: '100%' }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'light-dark(rgba(249, 115, 22, 0.1), rgba(249, 115, 22, 0.15))',
|
||||||
|
color: '#f97316',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={28} />
|
||||||
|
</Box>
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
{m.error__title()}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" ta="center" size="sm">
|
||||||
|
{m.error__hint()}
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button variant="light" onClick={() => window.location.reload()}>
|
||||||
|
{m.error__retry()}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{import.meta.env.DEV && detail ? (
|
||||||
|
<Stack gap={6} style={{ width: '100%' }}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
transform: open ? 'rotate(90deg)' : undefined,
|
||||||
|
transition: 'transform 120ms ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{m.error__details()}
|
||||||
|
</Button>
|
||||||
|
{open ? (
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'light-dark(#f8f9fa, #1a1b1e)',
|
||||||
|
border: '1px solid light-dark(#e9ecef, #2c2e33)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{asI18n(detail)}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
apps/app/src/components/LanguageSelector.tsx
Normal file
32
apps/app/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Select } from '@pikku/mantine/core'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { supportedLocales } from '@/i18n/config'
|
||||||
|
import { usePreferences } from '@/contexts/preferences'
|
||||||
|
|
||||||
|
const LOCALE_LABELS: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
fr: 'Français',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSelector() {
|
||||||
|
useLocale()
|
||||||
|
const { locale, setLocale } = usePreferences()
|
||||||
|
|
||||||
|
const data = supportedLocales.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: LOCALE_LABELS[code] ?? code.toUpperCase(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={m.preferences__language()}
|
||||||
|
data={data}
|
||||||
|
value={locale}
|
||||||
|
onChange={(v) => v && setLocale(v)}
|
||||||
|
size="xs"
|
||||||
|
w={110}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
apps/app/src/components/ThemeSelector.tsx
Normal file
27
apps/app/src/components/ThemeSelector.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Select } from '@pikku/mantine/core'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { themeList } from '@project/mantine-themes'
|
||||||
|
import { usePreferences } from '@/contexts/preferences'
|
||||||
|
|
||||||
|
// A single theme per app. Themes are normally chosen from the Fabric console
|
||||||
|
// (which live-injects into the iframe); this picker only appears when more than
|
||||||
|
// one theme ships, and is hidden otherwise.
|
||||||
|
export function ThemeSelector() {
|
||||||
|
useLocale()
|
||||||
|
const { themeId, setThemeId } = usePreferences()
|
||||||
|
|
||||||
|
if (themeList.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={m.preferences__theme()}
|
||||||
|
data={themeList.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
value={themeId}
|
||||||
|
onChange={(v) => v && setThemeId(v)}
|
||||||
|
size="xs"
|
||||||
|
w={110}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
apps/app/src/components/Wordmark.tsx
Normal file
39
apps/app/src/components/Wordmark.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { Box, Group, Text } from '@pikku/mantine/core'
|
||||||
|
import type { I18nString } from '@pikku/react'
|
||||||
|
|
||||||
|
// Placeholder brand mark + app name. A gradient tile (brand primary → secondary,
|
||||||
|
// both theme-driven so it recolours with the active theme) holding a bold spark
|
||||||
|
// glyph — a confident, generic starting point, NOT a flat monochrome square.
|
||||||
|
// Reshape the glyph into a real brand mark per project.
|
||||||
|
export const Wordmark: FC<{ name: I18nString; size?: number }> = ({ name, size = 30 }) => {
|
||||||
|
return (
|
||||||
|
<Group gap={10} align="center" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={size}
|
||||||
|
h={size}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
// Gradient falls back to the solid primary fill if the theme defines no
|
||||||
|
// secondary, so it always renders on-brand.
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(135deg, var(--mantine-primary-color-filled), var(--mantine-color-secondary-5, var(--mantine-primary-color-filled)))',
|
||||||
|
color: 'var(--mantine-primary-color-contrast)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width={size * 0.56} height={size * 0.56} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
{/* Four-point spark — energetic, distinctive, reads at any size. */}
|
||||||
|
<path d="M12 1.5c.9 5.1 4.5 8.7 9.6 9.6v1.8c-5.1.9-8.7 4.5-9.6 9.6h-1.8c-.9-5.1-4.5-8.7-9.6-9.6v-1.8c5.1-.9 8.7-4.5 9.6-9.6Z" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<Text fw={700} style={{ fontSize: size * 0.55, letterSpacing: '-0.02em' }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/app/src/contexts/preferences.tsx
Normal file
19
apps/app/src/contexts/preferences.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export interface PreferencesContextValue {
|
||||||
|
locale: string
|
||||||
|
themeId: string
|
||||||
|
setLocale: (locale: string) => void
|
||||||
|
setThemeId: (themeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreferencesContext = createContext<PreferencesContextValue>({
|
||||||
|
locale: 'en',
|
||||||
|
themeId: 'default',
|
||||||
|
setLocale: () => {},
|
||||||
|
setThemeId: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function usePreferences(): PreferencesContextValue {
|
||||||
|
return useContext(PreferencesContext)
|
||||||
|
}
|
||||||
46
apps/app/src/hooks/useAuthGate.ts
Normal file
46
apps/app/src/hooks/useAuthGate.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { getAuthSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
// Client-side auth gates. They run only after hydration (useEffect never fires
|
||||||
|
// during SSR), so the redirect decision never depends on the SSR worker seeing
|
||||||
|
// the session cookie — which it can't on the multi-tenant pikkufabric.dev
|
||||||
|
// domain, where the Auth.js cookie is host-only on the API origin and so is not
|
||||||
|
// sent to the app host on a server-rendered request. The client fetch to the
|
||||||
|
// API host does carry that cookie, so the check is correct on every deploy
|
||||||
|
// topology (local, platform subdomains, custom domain).
|
||||||
|
//
|
||||||
|
// Trade-off: a logged-out visitor to /app sees the shell for a moment before
|
||||||
|
// being redirected. On a custom domain (single tenant, cookie Domain=.<parent>)
|
||||||
|
// you could add a createServerFn + server-side gate to remove that flash —
|
||||||
|
// deferred; not safe to do generically on the shared platform domain.
|
||||||
|
|
||||||
|
export function useRedirectIfAuthenticated() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void getAuthSession().then((session) => {
|
||||||
|
if (!cancelled && session.user?.email) {
|
||||||
|
void navigate({ to: '/app' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequireAuthentication() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void getAuthSession().then((session) => {
|
||||||
|
if (!cancelled && !session.user?.email) {
|
||||||
|
void navigate({ to: '/login' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
}
|
||||||
87
apps/app/src/i18n/config.ts
Normal file
87
apps/app/src/i18n/config.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
|
||||||
|
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
|
||||||
|
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
|
||||||
|
// message functions (`m`).
|
||||||
|
//
|
||||||
|
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
|
||||||
|
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
|
||||||
|
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
|
||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
import { locales, baseLocale, overwriteGetLocale } from '../paraglide/runtime.js'
|
||||||
|
|
||||||
|
export const supportedLocales = locales
|
||||||
|
export const defaultLocale = baseLocale
|
||||||
|
export type Locale = (typeof locales)[number]
|
||||||
|
|
||||||
|
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||||
|
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||||
|
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
|
||||||
|
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||||
|
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectLocale(pathname: string): Locale {
|
||||||
|
const segment = pathname.split('/')[1]
|
||||||
|
if (supportedLocales.includes(segment as Locale)) return segment as Locale
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
const browserLang = navigator.language?.split('-')[0]
|
||||||
|
if (supportedLocales.includes(browserLang as Locale)) return browserLang as Locale
|
||||||
|
}
|
||||||
|
return defaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reactive locale store ────────────────────────────────────────────────────
|
||||||
|
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
|
||||||
|
// it to a tiny external store: `getLocale` reads `activeLocale`; components
|
||||||
|
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
|
||||||
|
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
|
||||||
|
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
|
||||||
|
let activeLocale: Locale = defaultLocale
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
overwriteGetLocale(() => activeLocale)
|
||||||
|
|
||||||
|
export function setActiveLocale(next: Locale): void {
|
||||||
|
if (next === activeLocale) return
|
||||||
|
activeLocale = next
|
||||||
|
for (const fn of listeners) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn: () => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
|
||||||
|
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
|
||||||
|
// used to live; it also returns the active locale + direction for components
|
||||||
|
// that need them (e.g. the language switcher).
|
||||||
|
export function useLocale(): {
|
||||||
|
locale: Locale
|
||||||
|
dir: 'ltr' | 'rtl'
|
||||||
|
setLocale: (l: Locale) => void
|
||||||
|
} {
|
||||||
|
const locale = useSyncExternalStore<Locale>(
|
||||||
|
subscribe,
|
||||||
|
() => activeLocale,
|
||||||
|
() => activeLocale,
|
||||||
|
)
|
||||||
|
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── i18n-debug masking ───────────────────────────────────────────────────────
|
||||||
|
// When enabled, every *translated* string is masked to block glyphs (█) so any
|
||||||
|
// readable text left on screen is text that never went through a message (a
|
||||||
|
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
|
||||||
|
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
|
||||||
|
export function isI18nDebug(): boolean {
|
||||||
|
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||||
|
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
|
||||||
|
export function maskI18n(s: string): string {
|
||||||
|
return isI18nDebug() ? s.replace(/\S/g, '█') : s
|
||||||
|
}
|
||||||
33
apps/app/src/i18n/messages.ts
Normal file
33
apps/app/src/i18n/messages.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Typed message functions for the app. `m.landing_title()` returns a branded
|
||||||
|
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
|
||||||
|
// `t('landing.title')` used to — no call-site boilerplate.
|
||||||
|
//
|
||||||
|
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
|
||||||
|
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
|
||||||
|
// per-message tree-shaking — fine here: locale files are KB and the app bundled
|
||||||
|
// all of en.json under i18next anyway.
|
||||||
|
import { m as _m } from '../paraglide/messages.js'
|
||||||
|
import type { I18nString } from '@pikku/react'
|
||||||
|
import { maskI18n } from './config.js'
|
||||||
|
|
||||||
|
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
|
||||||
|
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
|
||||||
|
|
||||||
|
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
|
||||||
|
const _wrapped: Record<string, unknown> = {}
|
||||||
|
for (const key of Object.keys(_raw)) {
|
||||||
|
const fn = _raw[key]
|
||||||
|
_wrapped[key] =
|
||||||
|
typeof fn === 'function'
|
||||||
|
? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args))
|
||||||
|
: fn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
|
||||||
|
export const m = _wrapped as unknown as Branded<typeof _m>
|
||||||
|
|
||||||
|
// Re-export asI18n so every call site imports BOTH i18n helpers from this one
|
||||||
|
// module — `import { asI18n, m } from '@/i18n/messages'`. asI18n brands an opaque
|
||||||
|
// runtime string (a server name/slug/id or a formatted date/number) so it passes
|
||||||
|
// the @pikku/mantine i18n gate; m.key() is for static copy. Keep them co-located.
|
||||||
|
export { asI18n } from '@pikku/react'
|
||||||
133
apps/app/src/lib/auth.ts
Normal file
133
apps/app/src/lib/auth.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { createAuthClient } from 'better-auth/client'
|
||||||
|
import { apiUrl } from './env'
|
||||||
|
|
||||||
|
// Better Auth browser client. It targets the worker's catch-all `/api/auth/**`
|
||||||
|
// routes (generated from src/auth.ts in the functions package). Pass the FULL
|
||||||
|
// auth base (`<apiUrl>/auth`): better-auth's withPath only appends its default
|
||||||
|
// `/api/auth` when baseURL has no path, and apiUrl() already carries `/api`, so
|
||||||
|
// a bare apiUrl() would leave the client calling `/api/get-session` (404) and
|
||||||
|
// the app would loop back to /login. Cookies ride every request so the session
|
||||||
|
// round-trips across origins.
|
||||||
|
//
|
||||||
|
// Lazily constructed: createAuthClient validates baseURL with `new URL()` at
|
||||||
|
// construction, so building it at module scope crashes SSR (apiUrl() returns
|
||||||
|
// the relative `/__api` placeholder there). Every caller runs in the browser.
|
||||||
|
let _authClient: ReturnType<typeof createAuthClient> | null = null
|
||||||
|
function authClient() {
|
||||||
|
_authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` })
|
||||||
|
return _authClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
user?: {
|
||||||
|
email?: string | null
|
||||||
|
name?: string | null
|
||||||
|
image?: string | null
|
||||||
|
} | null
|
||||||
|
expires?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thrown by signInWithPassword when the email/password pair is wrong.
|
||||||
|
export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
|
||||||
|
// Thrown by registerWithPassword when the email is already taken.
|
||||||
|
export const EMAIL_IN_USE = 'EMAIL_IN_USE'
|
||||||
|
|
||||||
|
export async function getAuthSession(): Promise<AuthSession> {
|
||||||
|
const { data } = await authClient().getSession()
|
||||||
|
if (!data?.user) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
email: data.user.email,
|
||||||
|
name: data.user.name ?? null,
|
||||||
|
image: data.user.image ?? null,
|
||||||
|
},
|
||||||
|
expires: data.session?.expiresAt ? new Date(data.session.expiresAt).toISOString() : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in with an email and password. On success Better Auth sets the session
|
||||||
|
// cookie; on bad credentials this throws INVALID_CREDENTIALS.
|
||||||
|
export async function signInWithPassword(email: string, password: string, _redirectPath = '/app') {
|
||||||
|
const { error } = await authClient().signIn.email({ email, password })
|
||||||
|
if (error) {
|
||||||
|
throw new Error(INVALID_CREDENTIALS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new account. Better Auth signs the user in on success (sets the
|
||||||
|
// session cookie), so they land logged in.
|
||||||
|
export async function registerWithPassword(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
options: { name?: string; redirectPath?: string } = {},
|
||||||
|
) {
|
||||||
|
const { error } = await authClient().signUp.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
// Better Auth requires a name; fall back to the local-part of the email.
|
||||||
|
name: options.name?.trim() || email.split('@')[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Better Auth returns 422 (UNPROCESSABLE_ENTITY) when the email is taken.
|
||||||
|
if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) {
|
||||||
|
throw new Error(EMAIL_IN_USE)
|
||||||
|
}
|
||||||
|
throw new Error('Unable to create account')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev-only convenience account. The Quick-login button (rendered only in non-prod
|
||||||
|
// builds — sandboxes run `vite dev`, deployments are production builds where
|
||||||
|
// import.meta.env.DEV is false) signs in with these so you never sign up by hand.
|
||||||
|
export const DEV_LOGIN = { email: 'test@example.com', password: 'password' } as const
|
||||||
|
|
||||||
|
// Sign in with the dev account, provisioning it on first use. Idempotent: if the
|
||||||
|
// account already exists we just sign in; a concurrent create (EMAIL_IN_USE) is
|
||||||
|
// fine. Only ever called from the dev-gated Quick-login button.
|
||||||
|
export async function devQuickLogin(): Promise<void> {
|
||||||
|
// The account usually already exists — try signing in first. A failure here is
|
||||||
|
// expected on the very first run (not yet provisioned), so fall through.
|
||||||
|
const signedIn = await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (signedIn) return
|
||||||
|
|
||||||
|
await registerWithPassword(DEV_LOGIN.email, DEV_LOGIN.password, { name: 'Test User' }).catch(
|
||||||
|
(err) => {
|
||||||
|
// Another tab/request may have created it between our sign-in and sign-up.
|
||||||
|
if (!(err instanceof Error && err.message === EMAIL_IN_USE)) throw err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with Google. Better Auth redirects the browser to the provider and
|
||||||
|
// back to `callbackURL` on success, so this never resolves on the happy path —
|
||||||
|
// it throws only when the provider isn't configured / the request is rejected.
|
||||||
|
// Configure the Google provider in the functions package's auth config to enable
|
||||||
|
// it; until then the button surfaces the error via useMutation.
|
||||||
|
export async function signInWithGoogle(callbackURL = '/app') {
|
||||||
|
const { error } = await authClient().signIn.social({ provider: 'google', callbackURL })
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Unable to continue with Google')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the signed-in user's password. Better Auth requires the current
|
||||||
|
// password to authorize the change.
|
||||||
|
export async function changePassword(currentPassword: string, newPassword: string) {
|
||||||
|
const { error } = await authClient().changePassword({ currentPassword, newPassword })
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Unable to update password')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
const { error } = await authClient().signOut()
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Unable to sign out')
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/app/src/lib/env.ts
Normal file
18
apps/app/src/lib/env.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Endpoints come from env, never hardcoded.
|
||||||
|
//
|
||||||
|
// In local dev VITE_API_URL is set at build time via .dev.vars and Vite
|
||||||
|
// replaces the import.meta.env reference inline. In CF Workers deployments
|
||||||
|
// VITE_API_URL is a runtime text binding — invisible to Vite at build time —
|
||||||
|
// so import.meta.env.VITE_API_URL is undefined in the bundle. We fall back to
|
||||||
|
// the current origin + /api, which is correct because the dispatcher worker
|
||||||
|
// routes every /api/* path to the API units on the same hostname.
|
||||||
|
export function apiUrl(): string {
|
||||||
|
if (import.meta.env.SSR) {
|
||||||
|
// SSR context: PikkuProvider is a client-side provider and its serverUrl
|
||||||
|
// is only consumed by hooks that run in the browser. Return the build-time
|
||||||
|
// var when available (local dev) or a placeholder so SSR never throws.
|
||||||
|
return import.meta.env.VITE_API_URL ?? '/__api'
|
||||||
|
}
|
||||||
|
// Client: use the build-time var (local dev) or derive from current origin.
|
||||||
|
return import.meta.env.VITE_API_URL ?? window.location.origin + '/api'
|
||||||
|
}
|
||||||
22
apps/app/src/pages/HomePage.tsx
Normal file
22
apps/app/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { Box, Stack, Text, Title } from '@pikku/mantine/core'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
|
||||||
|
export const HomePage: FC = () => {
|
||||||
|
useLocale()
|
||||||
|
const appName = m.app__name()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mih="70vh" style={{ display: 'grid', placeItems: 'center' }}>
|
||||||
|
<Stack maw={520} ta="center" gap="md">
|
||||||
|
<Title order={1} fz={34} fw={650} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
|
||||||
|
{m.home__title()}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="md" style={{ lineHeight: 1.6 }}>
|
||||||
|
{m.home__body({ name: appName })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
apps/app/src/pages/LoginPage.tsx
Normal file
78
apps/app/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { Anchor } from '@pikku/mantine/core'
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
|
||||||
|
import {
|
||||||
|
DEV_LOGIN,
|
||||||
|
INVALID_CREDENTIALS,
|
||||||
|
devQuickLogin,
|
||||||
|
signInWithGoogle,
|
||||||
|
signInWithPassword,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
export const LoginPage: FC = () => {
|
||||||
|
useLocale()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const appName = m.app__name()
|
||||||
|
// Dev builds (sandboxes run `vite dev`) prefill the test account and expose a
|
||||||
|
// one-click quick-login; production builds strip both (import.meta.env.DEV).
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const signIn = useMutation({
|
||||||
|
mutationFn: (values: AuthFormValues) =>
|
||||||
|
signInWithPassword(values.email, values.password, '/app'),
|
||||||
|
onSuccess: () => navigate({ to: '/app' }),
|
||||||
|
})
|
||||||
|
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
|
||||||
|
const quick = useMutation({
|
||||||
|
mutationFn: () => devQuickLogin(),
|
||||||
|
onSuccess: () => navigate({ to: '/app' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = signIn.isError
|
||||||
|
? signIn.error instanceof Error && signIn.error.message === INVALID_CREDENTIALS
|
||||||
|
? m.auth__login__invalid_credentials()
|
||||||
|
: m.auth__login__error()
|
||||||
|
: google.isError
|
||||||
|
? m.auth__google_error()
|
||||||
|
: quick.isError
|
||||||
|
? m.auth__login__error()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard
|
||||||
|
appName={appName}
|
||||||
|
title={m.auth__login__title()}
|
||||||
|
description={m.auth__login__description({ name: appName })}
|
||||||
|
cta={m.auth__login__cta()}
|
||||||
|
passwordAutoComplete="current-password"
|
||||||
|
busy={signIn.isPending}
|
||||||
|
googleBusy={google.isPending}
|
||||||
|
error={error}
|
||||||
|
initialValues={isDev ? { email: DEV_LOGIN.email, password: DEV_LOGIN.password } : undefined}
|
||||||
|
quickLogin={
|
||||||
|
isDev
|
||||||
|
? {
|
||||||
|
label: m.auth__dev_quick_login(),
|
||||||
|
hint: m.auth__dev_quick_login_hint(),
|
||||||
|
onClick: () => quick.mutate(),
|
||||||
|
busy: quick.isPending,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onAuthSubmit={(values) => signIn.mutate(values)}
|
||||||
|
onGoogle={() => google.mutate()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{m.auth__login__footer_prompt()}{' '}
|
||||||
|
<Anchor component={Link} to="/signup">
|
||||||
|
{m.auth__login__footer_action()}
|
||||||
|
</Anchor>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
apps/app/src/pages/SignupPage.tsx
Normal file
56
apps/app/src/pages/SignupPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { Anchor } from '@pikku/mantine/core'
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { m } from '@/i18n/messages'
|
||||||
|
import { useLocale } from '@/i18n/config'
|
||||||
|
import { AuthCard, type AuthFormValues } from '@/components/AuthCard'
|
||||||
|
import { EMAIL_IN_USE, registerWithPassword, signInWithGoogle } from '@/lib/auth'
|
||||||
|
|
||||||
|
export const SignupPage: FC = () => {
|
||||||
|
useLocale()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const appName = m.app__name()
|
||||||
|
|
||||||
|
const signUp = useMutation({
|
||||||
|
mutationFn: (values: AuthFormValues) =>
|
||||||
|
registerWithPassword(values.email, values.password, {
|
||||||
|
name: values.name,
|
||||||
|
redirectPath: '/app',
|
||||||
|
}),
|
||||||
|
onSuccess: () => navigate({ to: '/app' }),
|
||||||
|
})
|
||||||
|
const google = useMutation({ mutationFn: () => signInWithGoogle('/app') })
|
||||||
|
|
||||||
|
const error = signUp.isError
|
||||||
|
? signUp.error instanceof Error && signUp.error.message === EMAIL_IN_USE
|
||||||
|
? m.auth__signup__email_in_use()
|
||||||
|
: m.auth__signup__error()
|
||||||
|
: google.isError
|
||||||
|
? m.auth__google_error()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard
|
||||||
|
appName={appName}
|
||||||
|
title={m.auth__signup__title()}
|
||||||
|
description={m.auth__signup__description({ name: appName })}
|
||||||
|
cta={m.auth__signup__cta()}
|
||||||
|
includeName
|
||||||
|
passwordAutoComplete="new-password"
|
||||||
|
busy={signUp.isPending}
|
||||||
|
googleBusy={google.isPending}
|
||||||
|
error={error}
|
||||||
|
onAuthSubmit={(values) => signUp.mutate(values)}
|
||||||
|
onGoogle={() => google.mutate()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{m.auth__signup__footer_prompt()}{' '}
|
||||||
|
<Anchor component={Link} to="/login">
|
||||||
|
{m.auth__signup__footer_action()}
|
||||||
|
</Anchor>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
apps/app/src/router.tsx
Normal file
22
apps/app/src/router.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
import { DefaultErrorPage } from '@/components/DefaultErrorPage'
|
||||||
|
|
||||||
|
// TanStack Start discovers this `getRouter` factory to build a router per
|
||||||
|
// request (server) and once on the client (hydration). routeTree.gen.ts is
|
||||||
|
// generated by the tanstackStart() Vite plugin from src/routes/** — don't
|
||||||
|
// hand-edit it.
|
||||||
|
export function getRouter() {
|
||||||
|
return createRouter({
|
||||||
|
routeTree,
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultErrorComponent: DefaultErrorPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
178
apps/app/src/routes/__root.tsx
Normal file
178
apps/app/src/routes/__root.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ColorSchemeScript,
|
||||||
|
MantineProvider,
|
||||||
|
mantineHtmlProps,
|
||||||
|
type MantineThemeOverride,
|
||||||
|
} from '@pikku/mantine/core'
|
||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { PikkuProvider, createPikku } from '@pikku/react'
|
||||||
|
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
|
||||||
|
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
|
||||||
|
import {
|
||||||
|
activeColorScheme,
|
||||||
|
activeId,
|
||||||
|
activeTheme,
|
||||||
|
buildMantineTheme,
|
||||||
|
googleFontsHref,
|
||||||
|
themeColorSchemes,
|
||||||
|
themes,
|
||||||
|
type ColorScheme,
|
||||||
|
type Theme,
|
||||||
|
} from '@project/mantine-themes'
|
||||||
|
import { defaultLocale, localeDir, supportedLocales, setActiveLocale } from '@/i18n/config'
|
||||||
|
import { apiUrl } from '@/lib/env'
|
||||||
|
import { PreferencesContext } from '@/contexts/preferences'
|
||||||
|
|
||||||
|
const LOCALE_KEY = 'app-locale'
|
||||||
|
const THEME_KEY = 'app-theme'
|
||||||
|
// The active theme id THEME_KEY was saved against — a preference only holds
|
||||||
|
// while that theme is still the workspace's active one, so a builder-side
|
||||||
|
// switch (which changes active.json) always wins over a stale local pick.
|
||||||
|
const THEME_SAVED_ACTIVE_KEY = 'app-theme-saved-active'
|
||||||
|
|
||||||
|
const fontsHref = googleFontsHref()
|
||||||
|
|
||||||
|
// Root route owns the full HTML document for SSR (<html lang dir> so the tree
|
||||||
|
// mirrors for RTL locales) and wraps the app in the Mantine + react-query +
|
||||||
|
// Pikku providers. Mantine v8 SSR needs ColorSchemeScript in <head> and
|
||||||
|
// mantineHtmlProps on <html>; @mantine/core/styles.css ships the static CSS.
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ charSet: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ title: 'Pikku App' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: RootDocument,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootDocument() {
|
||||||
|
// Seed from the build-time active theme (active.json) so the applied theme
|
||||||
|
// drives the initial + SSR render; useEffect syncs from localStorage after
|
||||||
|
// hydration. Seeding 'default' here was the "applied theme never shows" bug.
|
||||||
|
const [themeId, setThemeIdRaw] = useState(activeId)
|
||||||
|
const [locale, setLocaleRaw] = useState(defaultLocale)
|
||||||
|
// Transient override from the fabric console live-preview (not persisted) —
|
||||||
|
// the console injects a full theme spec into the iframe so the builder sees
|
||||||
|
// the look instantly. Takes precedence over the persisted theme.
|
||||||
|
const [previewTheme, setPreviewTheme] = useState<MantineThemeOverride | null>(null)
|
||||||
|
// The scheme that goes with the live-preview spec (a light style must render
|
||||||
|
// light), tracked alongside previewTheme so a preview flips the scheme too.
|
||||||
|
const [previewScheme, setPreviewScheme] = useState<ColorScheme | null>(null)
|
||||||
|
|
||||||
|
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
|
||||||
|
// Force the active/previewed theme's natural scheme — a light-first style boots
|
||||||
|
// light, a dark-first style dark — rather than the old hardcoded dark.
|
||||||
|
const colorScheme: ColorScheme = previewScheme ?? themeColorSchemes[themeId] ?? activeColorScheme
|
||||||
|
|
||||||
|
// Sync preferences from localStorage after hydration.
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem(THEME_KEY)
|
||||||
|
const savedAgainst = localStorage.getItem(THEME_SAVED_ACTIVE_KEY)
|
||||||
|
if (savedTheme && themes[savedTheme] && savedAgainst === activeId) {
|
||||||
|
setThemeIdRaw(savedTheme)
|
||||||
|
} else if (savedTheme) {
|
||||||
|
localStorage.removeItem(THEME_KEY)
|
||||||
|
localStorage.removeItem(THEME_SAVED_ACTIVE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedLocale = localStorage.getItem(LOCALE_KEY)
|
||||||
|
if (
|
||||||
|
savedLocale &&
|
||||||
|
supportedLocales.includes(savedLocale as (typeof supportedLocales)[number])
|
||||||
|
) {
|
||||||
|
setLocaleRaw(savedLocale)
|
||||||
|
setActiveLocale(savedLocale as (typeof supportedLocales)[number])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keep <html lang dir> in sync with locale changes after hydration.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
document.documentElement.dir = localeDir(locale)
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
|
// Fabric console live-preview: postMessage injects a theme spec to override
|
||||||
|
// the active theme without persisting. Accepts `theme` (a full spec) or the
|
||||||
|
// legacy `palette`; null resets to the persisted theme.
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
const data = event.data
|
||||||
|
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
|
||||||
|
try {
|
||||||
|
const spec = (data.theme ?? data.palette) as Theme | null
|
||||||
|
setPreviewTheme(spec ? buildMantineTheme(spec) : null)
|
||||||
|
setPreviewScheme(spec?.structure?.defaultColorScheme ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[mantine-themes] ignoring bad theme payload', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('message', onMessage)
|
||||||
|
return () => window.removeEventListener('message', onMessage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setLocale = (next: string) => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, next)
|
||||||
|
setLocaleRaw(next)
|
||||||
|
setActiveLocale(next as (typeof supportedLocales)[number])
|
||||||
|
}
|
||||||
|
|
||||||
|
const setThemeId = (next: string) => {
|
||||||
|
localStorage.setItem(THEME_KEY, next)
|
||||||
|
localStorage.setItem(THEME_SAVED_ACTIVE_KEY, activeId)
|
||||||
|
setThemeIdRaw(next)
|
||||||
|
setPreviewTheme(null)
|
||||||
|
setPreviewScheme(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-render instances keep server requests from sharing client/cache state.
|
||||||
|
const [queryClient] = useState(() => new QueryClient())
|
||||||
|
const [pikku] = useState(() =>
|
||||||
|
createPikku(PikkuFetch, PikkuRPC, {
|
||||||
|
serverUrl: apiUrl(),
|
||||||
|
credentials: 'include',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} dir={localeDir(locale)} {...mantineHtmlProps}>
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
<ColorSchemeScript defaultColorScheme={activeColorScheme} />
|
||||||
|
{/* Inline data-URI icon so the browser's automatic /favicon.ico request
|
||||||
|
never 404s (no asset to ship, no network round-trip). Gradient spark
|
||||||
|
matches the Wordmark; the build agent rebrands this per project. */}
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234f46e5'/%3E%3Cstop offset='1' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='7' fill='url(%23g)'/%3E%3Cpath d='M16 4c1.1 6.4 5.6 10.9 12 12-6.4 1.1-10.9 5.6-12 12-1.1-6.4-5.6-10.9-12-12C10.4 14.9 14.9 10.4 16 4Z' fill='white'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
{fontsHref && (
|
||||||
|
<>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link rel="stylesheet" href={fontsHref} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<MantineProvider
|
||||||
|
theme={effectiveTheme}
|
||||||
|
forceColorScheme={colorScheme === 'auto' ? undefined : colorScheme}
|
||||||
|
>
|
||||||
|
<PreferencesContext.Provider value={{ locale, themeId, setLocale, setThemeId }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PikkuProvider pikku={pikku}>
|
||||||
|
<Outlet />
|
||||||
|
</PikkuProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</PreferencesContext.Provider>
|
||||||
|
</MantineProvider>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
apps/app/src/routes/app.index.tsx
Normal file
6
apps/app/src/routes/app.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HomePage } from '@/pages/HomePage'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/app/')({
|
||||||
|
component: HomePage,
|
||||||
|
})
|
||||||
12
apps/app/src/routes/app.tsx
Normal file
12
apps/app/src/routes/app.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '@/components/AppShell'
|
||||||
|
import { useRequireAuthentication } from '@/hooks/useAuthGate'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/app')({
|
||||||
|
component: AppLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
useRequireAuthentication()
|
||||||
|
return <AppShell />
|
||||||
|
}
|
||||||
11
apps/app/src/routes/index.tsx
Normal file
11
apps/app/src/routes/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// The starter has no marketing landing — entry is the sign-in screen. Redirect
|
||||||
|
// at the router level so it runs during SSR (and every navigation), instead of a
|
||||||
|
// client-only effect that leaves `/` blank until hydration. Signed-in visitors
|
||||||
|
// are then bounced to /app by /login's own auth gate.
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
},
|
||||||
|
})
|
||||||
12
apps/app/src/routes/login.tsx
Normal file
12
apps/app/src/routes/login.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
|
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
component: LoginRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function LoginRoute() {
|
||||||
|
useRedirectIfAuthenticated()
|
||||||
|
return <LoginPage />
|
||||||
|
}
|
||||||
12
apps/app/src/routes/signup.tsx
Normal file
12
apps/app/src/routes/signup.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { SignupPage } from '@/pages/SignupPage'
|
||||||
|
import { useRedirectIfAuthenticated } from '@/hooks/useAuthGate'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/signup')({
|
||||||
|
component: SignupRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SignupRoute() {
|
||||||
|
useRedirectIfAuthenticated()
|
||||||
|
return <SignupPage />
|
||||||
|
}
|
||||||
23
apps/app/tsconfig.json
Normal file
23
apps/app/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts", "wrangler.jsonc"]
|
||||||
|
}
|
||||||
59
apps/app/vite.config.ts
Normal file
59
apps/app/vite.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
|
import { paraglideVitePlugin } from '@inlang/paraglide-js'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
// Plain TanStack Start config (Node target) — used for local sandbox dev. This
|
||||||
|
// template is deploy-provider agnostic: it ships NO @cloudflare/vite-plugin. At
|
||||||
|
// deploy, fabric CI injects the CF adapter (vite.config.cf.ts merges cloudflare()
|
||||||
|
// on top of this config) to emit the CF Workers SSR bundle. Do not add cloudflare
|
||||||
|
// here — that would double-apply it and couple the template to a provider.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
// Compile messages/*.json → src/paraglide so `m`/`mKey` resolve, with HMR
|
||||||
|
// on message edits. Must run first.
|
||||||
|
paraglideVitePlugin({ project: './project.inlang', outdir: './src/paraglide' }),
|
||||||
|
tanstackStart(),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
|
// Local dev only — mirrors the sandbox edge (Caddy): /api/auth/* keeps its
|
||||||
|
// prefix (Better Auth mounts there), every other /api/* reaches the pikku
|
||||||
|
// dev server unprefixed (/rpc/...). In the sandbox Caddy handles /api
|
||||||
|
// before Vite, so this proxy never fires there.
|
||||||
|
// changeOrigin stays false so the backend sees Host = the app origin —
|
||||||
|
// Better Auth trusts the request origin only when it matches Host.
|
||||||
|
proxy: {
|
||||||
|
'/api/auth': {
|
||||||
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
// File content: the pikku dev server serves uploads (PUT) and assets (GET)
|
||||||
|
// AT these prefixes (pikku.config.json content.uploadUrlPrefix/assetUrlPrefix),
|
||||||
|
// so proxy them through WITHOUT a rewrite. `/content` (not `/assets`) avoids
|
||||||
|
// colliding with Vite/TanStack's own built asset paths. In the sandbox Caddy
|
||||||
|
// handles these before Vite, so this proxy only fires in local dev.
|
||||||
|
'/upload': {
|
||||||
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
'/content': {
|
||||||
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
9
apps/app/wrangler.jsonc
Normal file
9
apps/app/wrangler.jsonc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
// Consumed by @cloudflare/vite-plugin. `main` is TanStack Start's built-in
|
||||||
|
// server entry (no hand-written worker file); the plugin wires client assets
|
||||||
|
// and the ssr environment automatically.
|
||||||
|
"name": "app",
|
||||||
|
"main": "@tanstack/react-start/server-entry",
|
||||||
|
"compatibility_date": "2024-12-01",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
}
|
||||||
21
db/annotations.ts
Normal file
21
db/annotations.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Column annotations — compiled by `pikku db migrate` into db/annotations.gen.json,
|
||||||
|
// which drives BOTH the generated types/zod AND runtime value coercion.
|
||||||
|
//
|
||||||
|
// Declare a `kind` for every semantic date/bool/json column you add:
|
||||||
|
// - kind: 'date' → the column types as `Date` end-to-end. Write real `Date`
|
||||||
|
// objects, read back `Date` objects; the TEXT storage is coerced both ways.
|
||||||
|
// - kind: 'bool' → types as `boolean` over the INTEGER 0/1 storage.
|
||||||
|
// - kind: 'json' → parsed/serialized automatically.
|
||||||
|
// Without a kind, a SQLite date column is just `string` (write ISO strings via
|
||||||
|
// `new Date().toISOString()`) and a flag is `number` — that default is fine too;
|
||||||
|
// just follow the generated type, never fight it with `new Date()` writes or casts.
|
||||||
|
//
|
||||||
|
// Optional per column: security ('public' | 'private' | 'pii' | 'secret'),
|
||||||
|
// classification (anonymize strategy), tsType, format, description.
|
||||||
|
export const classifications = {
|
||||||
|
// Example — after adding a `todos` table in a migration:
|
||||||
|
// todos: {
|
||||||
|
// created_at: { kind: 'date' },
|
||||||
|
// done: { kind: 'bool' },
|
||||||
|
// },
|
||||||
|
}
|
||||||
3
db/sqlite-seed.sql
Normal file
3
db/sqlite-seed.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Dev seed (applied via `pikku db seed` after `pikku db migrate`).
|
||||||
|
-- The starter is auth-only, so there's nothing to seed yet. Add your own
|
||||||
|
-- INSERT … ON CONFLICT DO NOTHING rows here as you grow the schema.
|
||||||
6
db/sqlite-test-seed.sql
Normal file
6
db/sqlite-test-seed.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Test-only seed: a minimal user row so getSession resolves against `user`.
|
||||||
|
-- This file is only used by the function-tests harness (never in the sandbox
|
||||||
|
-- or production).
|
||||||
|
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES ('user-001', 'Test User', 'test@example.com', 1, datetime('now'), datetime('now'))
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
67
db/sqlite/0001-init.sql
Normal file
67
db/sqlite/0001-init.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- Better Auth core schema. These four tables (user, session, account,
|
||||||
|
-- verification) are owned and written entirely by Better Auth — the app never
|
||||||
|
-- inserts into them directly; sign-up / sign-in / sessions all flow through the
|
||||||
|
-- generated `/api/auth/**` routes (see src/auth.ts).
|
||||||
|
--
|
||||||
|
-- The schema is generated by Better Auth's own migration generator
|
||||||
|
-- (`getMigrations(...).compileMigrations()`); the columns are spelled in
|
||||||
|
-- snake_case to match the rest of the app's DB convention. Better Auth is handed
|
||||||
|
-- the app's kysely (with CamelCasePlugin) in src/auth.ts, so its camelCase field
|
||||||
|
-- names (emailVerified, userId, createdAt, ...) compile to exactly these
|
||||||
|
-- snake_case columns at runtime. To change this schema (add a provider field, a
|
||||||
|
-- plugin table, ...), re-run the generator rather than hand-editing.
|
||||||
|
--
|
||||||
|
-- Email + password is the default sign-in method. The credential hash is stored
|
||||||
|
-- by Better Auth in account.password (provider_id = 'credential'), never on the
|
||||||
|
-- user row.
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
"id" text not null primary key,
|
||||||
|
"name" text not null,
|
||||||
|
"email" text not null unique,
|
||||||
|
"email_verified" integer not null,
|
||||||
|
"image" text,
|
||||||
|
"created_at" date not null,
|
||||||
|
"updated_at" date not null
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "session" (
|
||||||
|
"id" text not null primary key,
|
||||||
|
"expires_at" date not null,
|
||||||
|
"token" text not null unique,
|
||||||
|
"created_at" date not null,
|
||||||
|
"updated_at" date not null,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text not null references "user" ("id") on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "account" (
|
||||||
|
"id" text not null primary key,
|
||||||
|
"account_id" text not null,
|
||||||
|
"provider_id" text not null,
|
||||||
|
"user_id" text not null references "user" ("id") on delete cascade,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" date,
|
||||||
|
"refresh_token_expires_at" date,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" date not null,
|
||||||
|
"updated_at" date not null
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification" (
|
||||||
|
"id" text not null primary key,
|
||||||
|
"identifier" text not null,
|
||||||
|
"value" text not null,
|
||||||
|
"expires_at" date not null,
|
||||||
|
"created_at" date not null,
|
||||||
|
"updated_at" date not null
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");
|
||||||
26
db/sqlite/0002-audit.sql
Normal file
26
db/sqlite/0002-audit.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Audit log. Fabric's audit service flushes events here via a per-stage
|
||||||
|
-- CF Queue. Add this table if you want audit capture; remove it (and skip
|
||||||
|
-- this migration) if your project has no audit requirements.
|
||||||
|
CREATE TABLE IF NOT EXISTS audit (
|
||||||
|
audit_id TEXT NOT NULL PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
outcome TEXT,
|
||||||
|
function_id TEXT,
|
||||||
|
wire_type TEXT,
|
||||||
|
trace_id TEXT,
|
||||||
|
transaction_id TEXT,
|
||||||
|
query_id TEXT,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
actor_org_id TEXT,
|
||||||
|
tables TEXT, -- JSON array of table names touched
|
||||||
|
changed_cols TEXT, -- JSON array of changed column names
|
||||||
|
event TEXT, -- custom event label
|
||||||
|
old TEXT, -- JSON: previous values
|
||||||
|
data TEXT -- JSON: new values / event payload
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_occurred_at ON audit (occurred_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit (actor_user_id) WHERE actor_user_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_function ON audit (function_id) WHERE function_id IS NOT NULL;
|
||||||
11
db/sqlite/0003-user-actor.sql
Normal file
11
db/sqlite/0003-user-actor.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- BETTER AUTH ACTOR PLUGIN — user-flow actors
|
||||||
|
-- Adds the `actor` boolean the Better Auth `actor()` plugin declares so its
|
||||||
|
-- schema is covered by an explicit migration and the db drift check passes.
|
||||||
|
-- Actor rows are synthetic users signed in by pikkuUserFlow via
|
||||||
|
-- POST /api/auth/sign-in/actor with the server-held USER_FLOW_ACTOR_SECRET;
|
||||||
|
-- the flag rides into the pikku core session so audits/analytics can address
|
||||||
|
-- synthetic traffic. A non-actor user can never be signed in this way.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE "user" ADD COLUMN "actor" integer NOT NULL DEFAULT 0;
|
||||||
16
db/sqlite/0004-admin.sql
Normal file
16
db/sqlite/0004-admin.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- BETTER AUTH ADMIN PLUGIN — user administration
|
||||||
|
-- Adds the columns the Better Auth `admin()` plugin declares so its schema is
|
||||||
|
-- covered by an explicit migration and the db drift check passes. The plugin
|
||||||
|
-- exposes /api/auth/admin/* (listUsers, setRole, ban, impersonateUser, …);
|
||||||
|
-- `impersonated_by` marks a session created via "view as user" so it can be
|
||||||
|
-- reverted with admin.stopImpersonating. All fields are optional — a user with
|
||||||
|
-- `role` = 'admin' is an administrator; everyone else stays a normal user.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE "user" ADD COLUMN "role" text;
|
||||||
|
ALTER TABLE "user" ADD COLUMN "banned" integer DEFAULT 0;
|
||||||
|
ALTER TABLE "user" ADD COLUMN "ban_reason" text;
|
||||||
|
ALTER TABLE "user" ADD COLUMN "ban_expires" date;
|
||||||
|
|
||||||
|
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;
|
||||||
13
db/sqlite/0005-fabric.sql
Normal file
13
db/sqlite/0005-fabric.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- BETTER AUTH FABRIC PLUGIN — Fabric operator admin sessions
|
||||||
|
-- Adds the `fabric` boolean the Better Auth `fabric()` plugin declares so its
|
||||||
|
-- schema is covered by an explicit migration and the db drift check passes.
|
||||||
|
-- Fabric rows are synthetic operator users minted by POST /api/auth/sign-in/fabric
|
||||||
|
-- after verifying a short-lived RS256 token the Fabric control plane signed
|
||||||
|
-- (checked against FABRIC_AUTH_PUBLIC_KEY). They are created with role 'admin' so
|
||||||
|
-- the console Users tab can list/impersonate real end-users WITHOUT the operator
|
||||||
|
-- being one of them; these rows are filtered out of any end-user listing. A real
|
||||||
|
-- user can never be signed in this way.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE "user" ADD COLUMN "fabric" integer NOT NULL DEFAULT 0;
|
||||||
34
e2e/README.md
Normal file
34
e2e/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# e2e — end-to-end tests (Cucumber + Playwright)
|
||||||
|
|
||||||
|
Two tiers:
|
||||||
|
|
||||||
|
## `@smoke` — the build gate (mutationless, runs against the live server)
|
||||||
|
|
||||||
|
Fast, read-only checks that run against the **already-running** app (the sandbox
|
||||||
|
dev server, or a local `yarn dev`). They never mutate domain data — they sign in
|
||||||
|
(an idempotent test-user sign-up) and navigate, nothing more — so they are safe
|
||||||
|
to run against the normal server on every build.
|
||||||
|
|
||||||
|
- `features/auth.feature` — sign in through the real login form.
|
||||||
|
- `features/pages.feature` — signed in, every static route must render with no
|
||||||
|
HTTP error, no failed/5xx app API call, no uncaught exception, and no console
|
||||||
|
error. New routes are picked up automatically from the generated route tree.
|
||||||
|
|
||||||
|
Run: `yarn workspace @project/e2e test` (the build agent calls this via the
|
||||||
|
`fabric-app-smoke` tool).
|
||||||
|
|
||||||
|
## `@e2e` — deeper behaviour tests (mutating, isolated server + DB)
|
||||||
|
|
||||||
|
Create/edit/delete flows and permission checks. Because they mutate state they
|
||||||
|
must be **deterministic**, so they run like the seminarhof harness: a separate
|
||||||
|
pikku dev server with its own golden/isolated DB per scenario, against the same
|
||||||
|
frontend (`E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1`). Add these as the app grows —
|
||||||
|
do NOT tag them `@smoke` (they are not part of the fast build gate).
|
||||||
|
|
||||||
|
## Extend
|
||||||
|
|
||||||
|
Add a `features/<domain>.feature` plus `steps/<domain>.steps.ts` per area. Reuse
|
||||||
|
the generic steps in `steps/common.steps.ts` and the `AppWorld` primitives
|
||||||
|
(`gotoApp`, `signIn`, `expectText`). Keep `common.steps.ts` and `world.ts`
|
||||||
|
generic. Tag a scenario `@smoke` only if it is mutationless and must pass on
|
||||||
|
every build.
|
||||||
21
e2e/package.json
Normal file
21
e2e/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@project/e2e",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "End-to-end tests \u2014 Cucumber + Playwright. Runs against the live app (sandbox dev server or a local dev server).",
|
||||||
|
"scripts": {
|
||||||
|
"test": "cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||||
|
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
|
||||||
|
"test:headed": "HEADED=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||||
|
"tsc": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cucumber/cucumber": "^11.0.0",
|
||||||
|
"@pikku/cucumber": "^0.12.11",
|
||||||
|
"@playwright/test": "^1.50.0",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
e2e/tests/cucumber.mjs
Normal file
6
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||||
|
paths: ['tests/features/**/*.feature'],
|
||||||
|
format: ['progress'],
|
||||||
|
forceExit: true,
|
||||||
|
}
|
||||||
8
e2e/tests/features/auth.feature
Normal file
8
e2e/tests/features/auth.feature
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@auth @smoke
|
||||||
|
Feature: Authentication
|
||||||
|
Users sign in with email + password; the session is cookie-based.
|
||||||
|
|
||||||
|
Scenario: a registered user signs in through the login form
|
||||||
|
Given a test account exists
|
||||||
|
When "the user" signs in through the login form
|
||||||
|
Then they land on the app
|
||||||
10
e2e/tests/features/pages.feature
Normal file
10
e2e/tests/features/pages.feature
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@pages @smoke
|
||||||
|
Feature: Every page loads
|
||||||
|
Signed in, every static route must render without an HTTP error, a failed or
|
||||||
|
5xx app API call, an uncaught exception, or a console error. This is the
|
||||||
|
baseline reliability gate — extend it with per-domain behaviour features as
|
||||||
|
the app grows (create/edit/list flows, permissions, etc.).
|
||||||
|
|
||||||
|
Scenario: all pages load cleanly when signed in
|
||||||
|
Given "the user" is signed in
|
||||||
|
Then every page loads without errors
|
||||||
28
e2e/tests/support/register.ts
Normal file
28
e2e/tests/support/register.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
Given,
|
||||||
|
When,
|
||||||
|
Then,
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
defineParameterType,
|
||||||
|
setDefaultTimeout,
|
||||||
|
} from '@cucumber/cucumber'
|
||||||
|
import { registerBrowserSteps, registerBrowserHooks } from '@pikku/cucumber/browser'
|
||||||
|
|
||||||
|
// The shared browser vocabulary ({actor} grammar, form/click/see steps, the
|
||||||
|
// "every page loads without errors" sweep) and the browser lifecycle hooks.
|
||||||
|
// Per-domain business steps belong in ../steps/*.steps.ts.
|
||||||
|
registerBrowserSteps({ Given, When, Then, defineParameterType })
|
||||||
|
registerBrowserHooks({ Before, After, setDefaultTimeout })
|
||||||
|
|
||||||
|
// The page-load sweep visits every route in one step, so give scenarios plenty
|
||||||
|
// of room. Per-action waits are still bounded by the package's config.timeout.
|
||||||
|
setDefaultTimeout(Number(process.env.E2E_SCENARIO_TIMEOUT ?? 300_000))
|
||||||
|
|
||||||
|
// Don't start a scenario while the dev server is mid-restart (it 5xx's /api
|
||||||
|
// for a few seconds on boot / file change) — that race reads as a phantom
|
||||||
|
// failure. Server orchestration itself stays outside these tests.
|
||||||
|
Before(async function (this: import('./world.js').AppWorld) {
|
||||||
|
const actor = await this.actor(undefined)
|
||||||
|
await actor.waitForServerReady()
|
||||||
|
})
|
||||||
15
e2e/tests/support/world.ts
Normal file
15
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { setWorldConstructor } from '@cucumber/cucumber'
|
||||||
|
import { BrowserWorld } from '@pikku/cucumber/browser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppWorld — the app's cucumber world, built on @pikku/cucumber/browser.
|
||||||
|
*
|
||||||
|
* The package owns the browser lifecycle, the actor grammar ("the user",
|
||||||
|
* "the admin", they) and the generic step vocabulary. Extend this class for
|
||||||
|
* app-specific needs: override createClients() to expose the generated
|
||||||
|
* PikkuRPC/PikkuFetch on actors, override resetAppData() to call the app's
|
||||||
|
* reset RPC, or add helpers for per-domain *.steps.ts files.
|
||||||
|
*/
|
||||||
|
export class AppWorld extends BrowserWorld {}
|
||||||
|
|
||||||
|
setWorldConstructor(AppWorld)
|
||||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["tests/**/*.ts"]
|
||||||
|
}
|
||||||
14
emails/locales/en.json
Normal file
14
emails/locales/en.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"footer": "If you did not create this account, you can safely ignore this email."
|
||||||
|
},
|
||||||
|
"confirmEmail": {
|
||||||
|
"subject": "Confirm your email for {{appName}}",
|
||||||
|
"preview": "Confirm your email address to finish setting up your account.",
|
||||||
|
"heading": "Confirm your email",
|
||||||
|
"intro": "Thanks for signing up for {{appName}}! Confirm {{email}} to finish setting up your account.",
|
||||||
|
"cta": "Confirm email",
|
||||||
|
"fallback": "If the button does not work, copy and paste this URL into your browser:",
|
||||||
|
"expiry": "This link expires in 24 hours."
|
||||||
|
}
|
||||||
|
}
|
||||||
3
emails/partials/footer.html
Normal file
3
emails/partials/footer.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<p style="margin:32px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
|
||||||
|
{{t.common.footer}}
|
||||||
|
</p>
|
||||||
13
emails/partials/layout.html
Normal file
13
emails/partials/layout.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{locale}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:{{theme.colors.canvas}};font-family:{{theme.fonts.body}};">
|
||||||
|
<div style="max-width:560px;margin:0 auto;padding:32px 16px;">
|
||||||
|
{{content}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
emails/templates/confirm-email.html
Normal file
27
emails/templates/confirm-email.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<p style="margin:0 0 12px;color:{{theme.colors.accent}};font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">
|
||||||
|
{{appName}}
|
||||||
|
</p>
|
||||||
|
<h1 style="margin:0 0 16px;color:{{theme.colors.text}};font-size:28px;line-height:1.1;">
|
||||||
|
{{t.confirmEmail.heading}}
|
||||||
|
</h1>
|
||||||
|
<p style="margin:0 0 24px;color:{{theme.colors.muted}};font-size:16px;line-height:1.6;">
|
||||||
|
{{t.confirmEmail.intro}}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;">
|
||||||
|
<a
|
||||||
|
href="{{confirmUrl}}"
|
||||||
|
style="display:inline-block;background:{{theme.colors.button}};color:{{theme.colors.buttonText}};text-decoration:none;padding:14px 22px;border-radius:999px;font-weight:700;"
|
||||||
|
>
|
||||||
|
{{t.confirmEmail.cta}}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 8px;color:{{theme.colors.text}};font-size:14px;line-height:1.6;">
|
||||||
|
{{t.confirmEmail.fallback}}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:{{theme.colors.muted}};font-size:14px;line-height:1.6;word-break:break-all;">
|
||||||
|
{{confirmUrl}}
|
||||||
|
</p>
|
||||||
|
<p style="margin:24px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;">
|
||||||
|
{{t.confirmEmail.expiry}}
|
||||||
|
</p>
|
||||||
|
{{> footer}}
|
||||||
1
emails/templates/confirm-email.subject.txt
Normal file
1
emails/templates/confirm-email.subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{t.confirmEmail.subject}}
|
||||||
7
emails/templates/confirm-email.text.txt
Normal file
7
emails/templates/confirm-email.text.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{t.confirmEmail.heading}}
|
||||||
|
|
||||||
|
{{t.confirmEmail.intro}}
|
||||||
|
|
||||||
|
{{confirmUrl}}
|
||||||
|
|
||||||
|
{{t.confirmEmail.expiry}}
|
||||||
16
emails/theme.json
Normal file
16
emails/theme.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"appName": "Pikku Starter",
|
||||||
|
"fonts": {
|
||||||
|
"body": "Inter, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"canvas": "#0b1020",
|
||||||
|
"surface": "#11182d",
|
||||||
|
"border": "#263252",
|
||||||
|
"text": "#f7f8fb",
|
||||||
|
"muted": "#a8b0c5",
|
||||||
|
"accent": "#7dd3fc",
|
||||||
|
"button": "#f59e0b",
|
||||||
|
"buttonText": "#111827"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
knowledge/design-language.md
Normal file
23
knowledge/design-language.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
type: Decision
|
||||||
|
title: Design language
|
||||||
|
description: UI/UX conventions decided for this app, applied consistently.
|
||||||
|
tags: [design, ui]
|
||||||
|
---
|
||||||
|
# Design Language
|
||||||
|
|
||||||
|
High-level UI/UX decisions for this app. Use component inspection and the codebase for implementation details — record only things here that aren't obvious from reading the code, and only once a decision has been made and should be applied consistently.
|
||||||
|
|
||||||
|
<!-- e.g.
|
||||||
|
- Drawers or modals? (which component, when each is used)
|
||||||
|
- Toast notifications or inline feedback? (library, placement, which events trigger each)
|
||||||
|
- Sidebar navigation: structure, depth levels, active state, how sections collapse
|
||||||
|
- Loading states: skeletons, spinners, or shimmer? where each is used
|
||||||
|
- Empty states: pattern and shared component if one exists
|
||||||
|
- Confirmation pattern: modal, inline expand, or separate page?
|
||||||
|
- Button hierarchy: primary / secondary / destructive variants
|
||||||
|
- Typography: font families and when each is used, label conventions
|
||||||
|
- Color token conventions: semantic names for status, surfaces, text
|
||||||
|
- Form layout: label style, field spacing, error placement
|
||||||
|
- Elevation / layering: how cards, panels, and overlays stack
|
||||||
|
-->
|
||||||
51
knowledge/index.md
Normal file
51
knowledge/index.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
type: Overview
|
||||||
|
title: Knowledge base
|
||||||
|
description: The builder agent's long-term memory, in the Open Knowledge Format (OKF).
|
||||||
|
tags: [meta, okf]
|
||||||
|
---
|
||||||
|
# Knowledge base
|
||||||
|
|
||||||
|
This folder is the builder agent's long-term memory of **this company and product** — short
|
||||||
|
markdown notes, not code. The agent reads these at the start of every session and updates
|
||||||
|
them as it learns durable facts.
|
||||||
|
|
||||||
|
## Record ONLY what pikku can't tell you
|
||||||
|
|
||||||
|
Never duplicate anything the platform already gives you for free. Database tables and
|
||||||
|
columns, function signatures, routes, wirings, permissions, and roles are all discoverable
|
||||||
|
with `pikku meta` and the database tools — do **not** copy them here. They drift the moment
|
||||||
|
the code changes, and a stale copy is worse than none.
|
||||||
|
|
||||||
|
Knowledge is the context introspection can't surface:
|
||||||
|
|
||||||
|
- **Domain concepts** — what the business's own terms mean
|
||||||
|
- **Decisions and their reasons** — *why* an approach was chosen, trade-offs accepted
|
||||||
|
- **Preferences** — style, scope, and things to avoid
|
||||||
|
- **Constraints** — external facts (a partner API's quirk, a compliance rule) agreed with the user
|
||||||
|
|
||||||
|
## Format — Open Knowledge Format (OKF v0.1)
|
||||||
|
|
||||||
|
Each note is a markdown file whose **path is its identity**, with YAML frontmatter and a
|
||||||
|
markdown body. Frontmatter fields (only `type` is required):
|
||||||
|
|
||||||
|
- `type` (required) — e.g. `Concept`, `Decision`, `Glossary`, `Preference`, `Overview`
|
||||||
|
- `title` — a short human label
|
||||||
|
- `description` — a one-line summary
|
||||||
|
- `tags` — a list for grouping and search, e.g. `[security, billing]`
|
||||||
|
- `resource` — a URL the note documents (a dashboard, doc, external service)
|
||||||
|
- `timestamp` — ISO-8601 of the last meaningful change
|
||||||
|
|
||||||
|
Reserved filenames: `index.md` (this section overview) and `log.md` (a chronological
|
||||||
|
history of changes). Cross-link notes with normal markdown links — they form a graph.
|
||||||
|
|
||||||
|
Suggested notes (create as needed):
|
||||||
|
|
||||||
|
- `product.md` — what the product is, who it's for, the core problem it solves
|
||||||
|
- `glossary.md` — the company's own terms and what they mean
|
||||||
|
- `decisions.md` — choices made together and why
|
||||||
|
- `preferences.md` — style, scope, and things to avoid
|
||||||
|
|
||||||
|
Keep notes concise and current. Do not store secrets or credentials here.
|
||||||
|
|
||||||
|
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
|
||||||
11
knowledge/security.md
Normal file
11
knowledge/security.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
type: Decision
|
||||||
|
title: Security
|
||||||
|
description: High-level, product-level security decisions for this app.
|
||||||
|
tags: [security]
|
||||||
|
---
|
||||||
|
# Security
|
||||||
|
|
||||||
|
High-level security decisions for this app. Use `pikku meta functions` to discover the actual auth functions, permissions, and roles — record only context here that isn't in the code.
|
||||||
|
|
||||||
|
<!-- e.g. what auth approach was chosen and why, who can access what at a product level, any known security constraints or decisions made with the user -->
|
||||||
11
knowledge/technology.md
Normal file
11
knowledge/technology.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
type: Decision
|
||||||
|
title: Technology
|
||||||
|
description: High-level technical decisions and context for this app.
|
||||||
|
tags: [technology, architecture]
|
||||||
|
---
|
||||||
|
# Technology
|
||||||
|
|
||||||
|
High-level technical decisions and context for this app. Use `pikku meta` and the database tools for implementation details — record only things here that aren't obvious from introspection.
|
||||||
|
|
||||||
|
<!-- e.g. why a particular approach was chosen, third-party services integrated and why, known constraints or trade-offs, architectural decisions made with the user -->
|
||||||
45
package.json
Normal file
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@project/root",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "bun@1.3.14",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*",
|
||||||
|
"design/*",
|
||||||
|
"e2e",
|
||||||
|
"!apps/_app_template_"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "cd packages/functions && pikku all",
|
||||||
|
"dev": "bun run --filter '@project/app' dev",
|
||||||
|
"tsc": "bun run --filter '*' tsc",
|
||||||
|
"lint": "oxlint --config ../.oxlintrc.json apps packages",
|
||||||
|
"format": "oxfmt --config ../.oxfmtrc.json apps packages",
|
||||||
|
"format:check": "oxfmt --check --config ../.oxfmtrc.json apps packages"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pikku/addon-console": "^0.12.23",
|
||||||
|
"@pikku/ai-vercel": "^0.12.7",
|
||||||
|
"@pikku/assistant-ui": "^0.12.7",
|
||||||
|
"@pikku/better-auth": "^0.12.16",
|
||||||
|
"@pikku/core": "^0.12.57",
|
||||||
|
"@pikku/cucumber": "^0.12.11",
|
||||||
|
"@pikku/deploy-standalone": "^0.12.7",
|
||||||
|
"@pikku/fetch": "^0.12.6",
|
||||||
|
"@pikku/kysely": "^0.13.0",
|
||||||
|
"@pikku/kysely-node-sqlite": "^0.12.3",
|
||||||
|
"@pikku/kysely-sqlite": "^0.12.7",
|
||||||
|
"@pikku/mantine": "^0.12.6",
|
||||||
|
"@pikku/react": "^0.12.5",
|
||||||
|
"@pikku/schema-cfworker": "^0.12.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@pikku/cli": "^0.12.76",
|
||||||
|
"oxfmt": "^0.53.0",
|
||||||
|
"oxlint": "^1.71.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@pikku/core": "0.12.57"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/components/index.ts
Normal file
24
packages/components/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export { UserCard } from './src/UserCard'
|
||||||
|
export type { User, UserCardProps } from './src/UserCard'
|
||||||
|
export { stubQuery } from './src/fixtures/stubQuery'
|
||||||
|
export { stubMutation } from './src/fixtures/stubMutation'
|
||||||
|
export { EmptyState } from './src/EmptyState'
|
||||||
|
export { NotFoundState } from './src/NotFoundState'
|
||||||
|
export { PageLoader } from './src/PageLoader'
|
||||||
|
export { ServerErrorState } from './src/ServerErrorState'
|
||||||
|
|
||||||
|
// Layout primitives — compose these instead of hand-rolling page chrome.
|
||||||
|
export { PageHeader } from './src/PageHeader'
|
||||||
|
export type { PageHeaderProps } from './src/PageHeader'
|
||||||
|
export { Panel } from './src/Panel'
|
||||||
|
export type { PanelProps } from './src/Panel'
|
||||||
|
export { StatCard } from './src/StatCard'
|
||||||
|
export type { StatCardProps } from './src/StatCard'
|
||||||
|
export { StatGrid } from './src/StatGrid'
|
||||||
|
export type { StatGridProps } from './src/StatGrid'
|
||||||
|
|
||||||
|
// Data presentation — feed these the output of a list/stats RPC you implement.
|
||||||
|
export { DataTable } from './src/DataTable'
|
||||||
|
export type { DataTableProps, DataTableColumn } from './src/DataTable'
|
||||||
|
export { BarChart } from './src/BarChart'
|
||||||
|
export type { BarChartProps, BarChartDatum } from './src/BarChart'
|
||||||
31
packages/components/package.json
Normal file
31
packages/components/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@project/components",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"tsc": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mantine/core": "^9.2.1",
|
||||||
|
"@mantine/hooks": "^9.2.1",
|
||||||
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"lucide-react": "^0.456.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"typescript": "^5.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "^9.2.1",
|
||||||
|
"@mantine/hooks": "^9.2.1",
|
||||||
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"lucide-react": "^0.456.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/components/src/BarChart.tsx
Normal file
57
packages/components/src/BarChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Group, Stack, Text } from '@mantine/core'
|
||||||
|
|
||||||
|
export interface BarChartDatum {
|
||||||
|
label: React.ReactNode
|
||||||
|
value: number
|
||||||
|
/** Mantine color key for the bar (e.g. 'teal', 'red'). Defaults to the theme primary. */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarChartProps {
|
||||||
|
data: BarChartDatum[]
|
||||||
|
/** Format the numeric value shown at the end of each bar. */
|
||||||
|
valueFormatter?: (value: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dependency-free horizontal bar chart (CSS only, theme colours). Feed it
|
||||||
|
// aggregated counts from a stats RPC — no charting library to install or break.
|
||||||
|
export function BarChart({ data, valueFormatter }: BarChartProps) {
|
||||||
|
const max = Math.max(1, ...data.map((d) => d.value))
|
||||||
|
const fmt = valueFormatter ?? ((n: number) => String(n))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{data.map((datum, index) => (
|
||||||
|
<Stack key={index} gap={4}>
|
||||||
|
<Group justify="space-between" gap="xs">
|
||||||
|
<Text size="sm">{datum.label}</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{fmt(datum.value)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 'var(--mantine-radius-xl)',
|
||||||
|
background: 'var(--mantine-color-default-hover)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${(datum.value / max) * 100}%`,
|
||||||
|
borderRadius: 'var(--mantine-radius-xl)',
|
||||||
|
background: datum.color
|
||||||
|
? `var(--mantine-color-${datum.color}-6)`
|
||||||
|
: 'var(--mantine-primary-color-6)',
|
||||||
|
transition: 'width 200ms ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
packages/components/src/DataTable.tsx
Normal file
85
packages/components/src/DataTable.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Center, Loader, Table, Text } from '@mantine/core'
|
||||||
|
|
||||||
|
export interface DataTableColumn<Row> {
|
||||||
|
/** Stable column id; also the default cell value (row[key]) when no render is given. */
|
||||||
|
key: string
|
||||||
|
header: React.ReactNode
|
||||||
|
/** Custom cell renderer. Defaults to String(row[key]). */
|
||||||
|
render?: (row: Row) => React.ReactNode
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
width?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<Row> {
|
||||||
|
columns: DataTableColumn<Row>[]
|
||||||
|
rows: Row[]
|
||||||
|
/** Stable key per row (e.g. r => r.taskId). */
|
||||||
|
rowKey: (row: Row) => string
|
||||||
|
loading?: boolean
|
||||||
|
/** Shown when there are no rows (pass an <EmptyState/> for a rich version). */
|
||||||
|
empty?: React.ReactNode
|
||||||
|
onRowClick?: (row: Row) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// A generic, typed table. Wire it to any list RPC: implement listX, pass its
|
||||||
|
// rows here, and describe the columns. Pure presentation — no data fetching.
|
||||||
|
export function DataTable<Row>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
loading,
|
||||||
|
empty,
|
||||||
|
onRowClick,
|
||||||
|
}: DataTableProps<Row>) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
{empty ?? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Nothing here yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table highlightOnHover={!!onRowClick} verticalSpacing="sm" horizontalSpacing="md">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<Table.Th key={col.key} style={{ textAlign: col.align ?? 'left', width: col.width }}>
|
||||||
|
{col.header}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={rowKey(row)}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<Table.Td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
|
||||||
|
{col.render
|
||||||
|
? col.render(row)
|
||||||
|
: String((row as Record<string, unknown>)[col.key] ?? '')}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
packages/components/src/EmptyState.stories.tsx
Normal file
58
packages/components/src/EmptyState.stories.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { AlertCircle, Boxes, FolderOpen } from 'lucide-react'
|
||||||
|
import type { Story, StoryMeta } from './csf.types'
|
||||||
|
import { EmptyState } from './EmptyState'
|
||||||
|
|
||||||
|
const meta: StoryMeta = {
|
||||||
|
title: 'EmptyState',
|
||||||
|
component: EmptyState,
|
||||||
|
tags: ['feedback'],
|
||||||
|
argTypes: {
|
||||||
|
icon: { description: 'Lucide icon component displayed above the title', control: false },
|
||||||
|
title: { description: 'Primary heading text', control: 'text' },
|
||||||
|
description: { description: 'Supporting text below the title', control: 'text' },
|
||||||
|
action: { description: 'Label for the primary action button', control: 'text' },
|
||||||
|
onAction: { description: 'Callback fired when the action button is clicked', control: false },
|
||||||
|
compact: {
|
||||||
|
description: 'Reduces padding and icon size for inline contexts',
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
docsHref: { description: 'Optional link shown as a Docs button', control: 'text' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
function DefaultStory() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Boxes}
|
||||||
|
title="No items yet"
|
||||||
|
description="Create your first item to get started."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const Default: Story = { render: DefaultStory }
|
||||||
|
|
||||||
|
function WithActionStory() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={FolderOpen}
|
||||||
|
title="No projects"
|
||||||
|
description="You don't have any projects yet."
|
||||||
|
action="New project"
|
||||||
|
onAction={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const WithAction: Story = { render: WithActionStory }
|
||||||
|
|
||||||
|
function CompactStory() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Nothing here"
|
||||||
|
description="This section is currently empty."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const Compact: Story = { render: CompactStory }
|
||||||
77
packages/components/src/EmptyState.tsx
Normal file
77
packages/components/src/EmptyState.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Button, Center, Group, Stack, Text } from '@mantine/core'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: React.ComponentType<{ size?: number; strokeWidth?: number }>
|
||||||
|
title: string
|
||||||
|
description: React.ReactNode
|
||||||
|
action?: string
|
||||||
|
onAction?: () => void
|
||||||
|
actionIcon?: React.ReactNode
|
||||||
|
actionLoading?: boolean
|
||||||
|
secondaryText?: React.ReactNode
|
||||||
|
docsHref?: string
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
onAction,
|
||||||
|
actionIcon,
|
||||||
|
actionLoading,
|
||||||
|
secondaryText,
|
||||||
|
docsHref,
|
||||||
|
compact = false,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const Icon = icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flex={1}>
|
||||||
|
<Stack
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap={compact ? 'sm' : 'md'}
|
||||||
|
py={compact ? 'lg' : 'xl'}
|
||||||
|
style={{ minHeight: compact ? undefined : '60vh', width: '100%' }}
|
||||||
|
>
|
||||||
|
<Icon size={compact ? 36 : 48} strokeWidth={1} />
|
||||||
|
<Text size={compact ? 'lg' : 'xl'} fw={600}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" ta="center" maw={500}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
{docsHref || (action && onAction) ? (
|
||||||
|
<Group justify="center">
|
||||||
|
{docsHref ? (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={docsHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ExternalLink size={16} />}
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{action && onAction ? (
|
||||||
|
<Button onClick={onAction} loading={actionLoading} leftSection={actionIcon}>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{secondaryText ? (
|
||||||
|
<Text c="dimmed" size="sm" ta="center" maw={500}>
|
||||||
|
{secondaryText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
packages/components/src/NotFoundState.stories.tsx
Normal file
25
packages/components/src/NotFoundState.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Story, StoryMeta } from './csf.types'
|
||||||
|
import { NotFoundState } from './NotFoundState'
|
||||||
|
|
||||||
|
const meta: StoryMeta = {
|
||||||
|
title: 'NotFoundState',
|
||||||
|
component: NotFoundState,
|
||||||
|
tags: ['feedback'],
|
||||||
|
argTypes: {
|
||||||
|
title: { description: 'Override the "Page not found" heading', control: 'text' },
|
||||||
|
description: { description: 'Override the default description text', control: 'text' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
|
|
||||||
|
function CustomStory() {
|
||||||
|
return (
|
||||||
|
<NotFoundState
|
||||||
|
title="Board not found"
|
||||||
|
description="The board you were looking for has been removed or never existed."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const Custom: Story = { render: CustomStory }
|
||||||
40
packages/components/src/NotFoundState.tsx
Normal file
40
packages/components/src/NotFoundState.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Center, Stack, Text } from '@mantine/core'
|
||||||
|
|
||||||
|
interface NotFoundStateProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFoundState({
|
||||||
|
title = 'Page not found',
|
||||||
|
description = "The resource you're looking for doesn't exist or has been removed.",
|
||||||
|
}: NotFoundStateProps) {
|
||||||
|
return (
|
||||||
|
<Center flex={1}>
|
||||||
|
<Stack
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
py="xl"
|
||||||
|
style={{ minHeight: '60vh', width: '100%' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 72,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: '-0.06em',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={600}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text ta="center" maw={520} c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
packages/components/src/PageHeader.stories.tsx
Normal file
32
packages/components/src/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from '@mantine/core'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import type { Story, StoryMeta } from './csf.types'
|
||||||
|
import { PageHeader } from './PageHeader'
|
||||||
|
|
||||||
|
const meta: StoryMeta = {
|
||||||
|
title: 'PageHeader',
|
||||||
|
component: PageHeader,
|
||||||
|
tags: ['layout'],
|
||||||
|
argTypes: {
|
||||||
|
title: { description: 'Page title', control: 'text' },
|
||||||
|
description: { description: 'Optional sub-text', control: 'text' },
|
||||||
|
actions: { description: 'Right-aligned actions slot', control: false },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
function DefaultStory() {
|
||||||
|
return <PageHeader title="Tasks" description="Everything on your plate, in one place." />
|
||||||
|
}
|
||||||
|
export const Default: Story = { render: DefaultStory }
|
||||||
|
|
||||||
|
function WithActionStory() {
|
||||||
|
return (
|
||||||
|
<PageHeader
|
||||||
|
title="Tasks"
|
||||||
|
description="Everything on your plate, in one place."
|
||||||
|
actions={<Button leftSection={<Plus size={16} />}>New task</Button>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const WithAction: Story = { render: WithActionStory }
|
||||||
32
packages/components/src/PageHeader.tsx
Normal file
32
packages/components/src/PageHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Group, Stack, Text, Title } from '@mantine/core'
|
||||||
|
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
/** Page title. Pass an i18n string from the app (m.foo()). */
|
||||||
|
title: React.ReactNode
|
||||||
|
/** Optional one-line description under the title. */
|
||||||
|
description?: React.ReactNode
|
||||||
|
/** Right-aligned actions (buttons, menus). */
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single shared page header every screen uses: a title, optional
|
||||||
|
// description, and a right-aligned action slot. Keeps headers consistent across
|
||||||
|
// pages — compose it, don't hand-roll a per-page header.
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" align="flex-end" wrap="nowrap" mb="xl">
|
||||||
|
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||||
|
<Title order={1} fz={26} fw={680} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
{description ? (
|
||||||
|
<Text c="dimmed" size="sm" style={{ lineHeight: 1.5 }}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
{actions ? <Box style={{ flexShrink: 0 }}>{actions}</Box> : null}
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
packages/components/src/PageLoader.stories.tsx
Normal file
11
packages/components/src/PageLoader.stories.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Story, StoryMeta } from './csf.types'
|
||||||
|
import { PageLoader } from './PageLoader'
|
||||||
|
|
||||||
|
const meta: StoryMeta = {
|
||||||
|
title: 'PageLoader',
|
||||||
|
component: PageLoader,
|
||||||
|
tags: ['loading'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
16
packages/components/src/PageLoader.tsx
Normal file
16
packages/components/src/PageLoader.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Center, Loader } from '@mantine/core'
|
||||||
|
|
||||||
|
export function PageLoader() {
|
||||||
|
return (
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader size="md" />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
packages/components/src/Panel.tsx
Normal file
48
packages/components/src/Panel.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Card, Group, Stack, Text, Title } from '@mantine/core'
|
||||||
|
|
||||||
|
export interface PanelProps {
|
||||||
|
/** Optional panel heading. */
|
||||||
|
title?: React.ReactNode
|
||||||
|
/** Optional sub-text under the heading. */
|
||||||
|
description?: React.ReactNode
|
||||||
|
/** Right-aligned header actions. */
|
||||||
|
actions?: React.ReactNode
|
||||||
|
/** Panel body. */
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Remove inner padding (e.g. for a flush table). */
|
||||||
|
noPadding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bordered content section with an optional titled header. The workhorse
|
||||||
|
// container — group related content into Panels instead of loose Cards.
|
||||||
|
export function Panel({ title, description, actions, children, noPadding }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding={noPadding ? 0 : 'lg'}>
|
||||||
|
{title || actions ? (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-end"
|
||||||
|
wrap="nowrap"
|
||||||
|
p={noPadding ? 'lg' : 0}
|
||||||
|
pb={noPadding ? 'sm' : 'md'}
|
||||||
|
>
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
{title ? (
|
||||||
|
<Title order={2} fz={16} fw={620} style={{ letterSpacing: '-0.02em' }}>
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
) : null}
|
||||||
|
{description ? (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
{actions ? <div style={{ flexShrink: 0 }}>{actions}</div> : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
packages/components/src/ServerErrorState.stories.tsx
Normal file
47
packages/components/src/ServerErrorState.stories.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Story, StoryMeta } from './csf.types'
|
||||||
|
import { ServerErrorState } from './ServerErrorState'
|
||||||
|
|
||||||
|
const meta: StoryMeta = {
|
||||||
|
title: 'ServerErrorState',
|
||||||
|
component: ServerErrorState,
|
||||||
|
tags: ['feedback'],
|
||||||
|
argTypes: {
|
||||||
|
title: { description: 'Override the "Something went wrong" heading', control: 'text' },
|
||||||
|
description: { description: 'Override the default error description', control: 'text' },
|
||||||
|
onRetry: { description: 'Callback fired when the retry button is clicked', control: false },
|
||||||
|
retrying: {
|
||||||
|
description: 'Shows a loading spinner on the retry button',
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
retryLabel: { description: 'Label for the retry button', control: 'text' },
|
||||||
|
glyph: {
|
||||||
|
description: 'Custom element displayed above the title instead of "500"',
|
||||||
|
control: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
|
|
||||||
|
function WithRetryStory() {
|
||||||
|
const [retrying, setRetrying] = useState(false)
|
||||||
|
const handleRetry = () => {
|
||||||
|
setRetrying(true)
|
||||||
|
setTimeout(() => setRetrying(false), 1500)
|
||||||
|
}
|
||||||
|
return <ServerErrorState onRetry={handleRetry} retrying={retrying} />
|
||||||
|
}
|
||||||
|
export const WithRetry: Story = { render: WithRetryStory }
|
||||||
|
|
||||||
|
function CustomGlyphStory() {
|
||||||
|
return (
|
||||||
|
<ServerErrorState
|
||||||
|
glyph={<span style={{ fontSize: 64, lineHeight: 1 }}>⚠️</span>}
|
||||||
|
title="Unexpected error"
|
||||||
|
description="Please try again or contact support if the problem persists."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export const CustomGlyph: Story = { render: CustomGlyphStory }
|
||||||
70
packages/components/src/ServerErrorState.tsx
Normal file
70
packages/components/src/ServerErrorState.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Button, Center, Stack, Text } from '@mantine/core'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ServerErrorStateProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
retrying?: boolean
|
||||||
|
retryLabel?: string
|
||||||
|
glyph?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerErrorState({
|
||||||
|
title = 'Something went wrong',
|
||||||
|
description = 'The server returned an error while loading this page.',
|
||||||
|
onRetry,
|
||||||
|
retrying = false,
|
||||||
|
retryLabel = 'Retry',
|
||||||
|
glyph,
|
||||||
|
}: ServerErrorStateProps) {
|
||||||
|
const normalizedDescription = typeof description === 'string' ? description.trim() : ''
|
||||||
|
const showDescription =
|
||||||
|
normalizedDescription.length > 0 &&
|
||||||
|
normalizedDescription !== 'HTTP 500' &&
|
||||||
|
normalizedDescription !== 'HTTP 500.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flex={1}>
|
||||||
|
<Stack
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
py="xl"
|
||||||
|
style={{ minHeight: '60vh', width: '100%' }}
|
||||||
|
>
|
||||||
|
{glyph ?? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 72,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: '-0.06em',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
500
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xl" fw={600}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{showDescription ? (
|
||||||
|
<Text ta="center" maw={520} c="dimmed">
|
||||||
|
{normalizedDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{onRetry ? (
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
loading={retrying}
|
||||||
|
leftSection={<RefreshCw size={16} />}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{retryLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
packages/components/src/StatCard.tsx
Normal file
37
packages/components/src/StatCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Card, Group, Stack, Text, ThemeIcon } from '@mantine/core'
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
/** Metric label, e.g. "Open tasks". */
|
||||||
|
label: React.ReactNode
|
||||||
|
/** The big number / value. */
|
||||||
|
value: React.ReactNode
|
||||||
|
/** Optional Lucide icon component. */
|
||||||
|
icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>
|
||||||
|
/** Theme color for the icon tile (Mantine color key). Defaults to primary. */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single metric tile: label, large value, optional icon. Compose several in a
|
||||||
|
// StatGrid for a dashboard summary row.
|
||||||
|
export function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={550} tt="uppercase" style={{ letterSpacing: '0.04em' }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={30} fw={700} lh={1} style={{ letterSpacing: '-0.03em' }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{Icon ? (
|
||||||
|
<ThemeIcon variant="light" color={color} radius="md" size={38}>
|
||||||
|
<Icon size={20} strokeWidth={1.8} />
|
||||||
|
</ThemeIcon>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
packages/components/src/StatGrid.tsx
Normal file
21
packages/components/src/StatGrid.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { SimpleGrid } from '@mantine/core'
|
||||||
|
import { StatCard, type StatCardProps } from './StatCard'
|
||||||
|
|
||||||
|
export interface StatGridProps {
|
||||||
|
stats: StatCardProps[]
|
||||||
|
/** Columns on wide screens (responsive down to 1). Defaults to stats.length capped at 4. */
|
||||||
|
columns?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// A responsive row of StatCards. Feed it the metrics from a stats RPC
|
||||||
|
// (e.g. getTaskStats) mapped to {label, value, icon}.
|
||||||
|
export function StatGrid({ stats, columns }: StatGridProps) {
|
||||||
|
const cols = columns ?? Math.min(stats.length || 1, 4)
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: cols }} spacing="md">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<StatCard key={index} {...stat} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
packages/components/src/UserCard.tsx
Normal file
83
packages/components/src/UserCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Avatar, Badge, Card, Group, Loader, Stack, Text } from '@mantine/core'
|
||||||
|
import type { UseQueryResult } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
status: 'active' | 'away' | 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<User['status'], string> = {
|
||||||
|
active: 'green',
|
||||||
|
away: 'yellow',
|
||||||
|
offline: 'gray',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCardProps = {
|
||||||
|
query: UseQueryResult<User | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserCard: React.FC<UserCardProps> = ({ query }) => {
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group>
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading user…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text fw={600} c="red">
|
||||||
|
Couldn’t load user
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{query.error instanceof Error ? query.error.message : 'Unknown error'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = query.data
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No user to show.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
<Avatar color="initials" name={user.name} radius="xl" />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600}>{user.name}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[user.status]} variant="light">
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" mt="md">
|
||||||
|
{user.role}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
packages/components/src/csf.types.ts
Normal file
21
packages/components/src/csf.types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
|
export interface ArgType {
|
||||||
|
description?: string
|
||||||
|
control?: string | false
|
||||||
|
defaultValue?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryMeta {
|
||||||
|
title: string
|
||||||
|
component: ComponentType<any>
|
||||||
|
description?: string
|
||||||
|
tags?: string[]
|
||||||
|
argTypes?: Record<string, ArgType>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Story {
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
render?: ComponentType<any>
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
69
packages/components/src/fixtures/stubMutation.ts
Normal file
69
packages/components/src/fixtures/stubMutation.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { UseMutationResult } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
function build<TData, TVariables>(
|
||||||
|
partial: Record<string, unknown>,
|
||||||
|
): UseMutationResult<TData, Error, TVariables> {
|
||||||
|
return {
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
isPaused: false,
|
||||||
|
submittedAt: 0,
|
||||||
|
context: undefined,
|
||||||
|
variables: undefined,
|
||||||
|
mutate: () => undefined,
|
||||||
|
mutateAsync: () => Promise.resolve(undefined as never),
|
||||||
|
reset: () => undefined,
|
||||||
|
...partial,
|
||||||
|
} as unknown as UseMutationResult<TData, Error, TVariables>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubMutation = {
|
||||||
|
idle<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
|
||||||
|
return build<TData, TVariables>({
|
||||||
|
status: 'idle',
|
||||||
|
isIdle: true,
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
data: undefined,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
pending<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
|
||||||
|
return build<TData, TVariables>({
|
||||||
|
status: 'pending',
|
||||||
|
isIdle: false,
|
||||||
|
isPending: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
data: undefined,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
error<TData = unknown, TVariables = unknown>(
|
||||||
|
error: Error,
|
||||||
|
): UseMutationResult<TData, Error, TVariables> {
|
||||||
|
return build<TData, TVariables>({
|
||||||
|
status: 'error',
|
||||||
|
isIdle: false,
|
||||||
|
isPending: false,
|
||||||
|
isError: true,
|
||||||
|
isSuccess: false,
|
||||||
|
data: undefined,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
success<TData = unknown, TVariables = unknown>(
|
||||||
|
data: TData,
|
||||||
|
): UseMutationResult<TData, Error, TVariables> {
|
||||||
|
return build<TData, TVariables>({
|
||||||
|
status: 'success',
|
||||||
|
isIdle: false,
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
66
packages/components/src/fixtures/stubQuery.ts
Normal file
66
packages/components/src/fixtures/stubQuery.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { UseQueryResult } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
function build<T>(partial: Record<string, unknown>): UseQueryResult<T> {
|
||||||
|
return {
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
isFetched: true,
|
||||||
|
isFetchedAfterMount: true,
|
||||||
|
isFetching: partial.fetchStatus === 'fetching',
|
||||||
|
isPaused: false,
|
||||||
|
isPlaceholderData: false,
|
||||||
|
isRefetching: false,
|
||||||
|
isStale: false,
|
||||||
|
isInitialLoading: partial.isLoading === true,
|
||||||
|
isLoadingError: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
dataUpdatedAt: 0,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
refetch: () => Promise.resolve(undefined as never),
|
||||||
|
promise: Promise.resolve(undefined as never),
|
||||||
|
...partial,
|
||||||
|
} as unknown as UseQueryResult<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubQuery = {
|
||||||
|
loading<T>(): UseQueryResult<T> {
|
||||||
|
return build<T>({
|
||||||
|
status: 'pending',
|
||||||
|
fetchStatus: 'fetching',
|
||||||
|
isPending: true,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
data: undefined,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
error<T>(error: Error): UseQueryResult<T> {
|
||||||
|
return build<T>({
|
||||||
|
status: 'error',
|
||||||
|
fetchStatus: 'idle',
|
||||||
|
isPending: false,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
isSuccess: false,
|
||||||
|
data: undefined,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
success<T>(data: T): UseQueryResult<T> {
|
||||||
|
return build<T>({
|
||||||
|
status: 'success',
|
||||||
|
fetchStatus: 'idle',
|
||||||
|
isPending: false,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
empty<T>(): UseQueryResult<T> {
|
||||||
|
return stubQuery.success<T>(null as T)
|
||||||
|
},
|
||||||
|
}
|
||||||
14
packages/components/tsconfig.json
Normal file
14
packages/components/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src"]
|
||||||
|
}
|
||||||
19
packages/functions-sdk/package.json
Normal file
19
packages/functions-sdk/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@project/functions-sdk",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./pikku/api.gen": "./src/pikku/api.gen.ts",
|
||||||
|
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
|
||||||
|
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
|
||||||
|
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
|
||||||
|
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
|
||||||
|
"./pikku/*": "./src/pikku/*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pikku/fetch": "^0.12.6",
|
||||||
|
"@pikku/react": "^0.12.5",
|
||||||
|
"@tanstack/react-query": "^5.66.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/functions/.pikku/agent/pikku-agent-map.gen.d.ts
vendored
Normal file
4
packages/functions/.pikku/agent/pikku-agent-map.gen.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
export type AgentMap = {}
|
||||||
26
packages/functions/.pikku/auth/auth.types.ts
Normal file
26
packages/functions/.pikku/auth/auth.types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
// AUTO-GENERATED by pikku CLI — do not edit
|
||||||
|
|
||||||
|
import { pikkuBetterAuth as _pikkuBetterAuth } from '@pikku/better-auth'
|
||||||
|
import type { BetterAuthInstance, PikkuBetterAuthFactory } from '@pikku/better-auth'
|
||||||
|
import type { SingletonServices } from '../function/pikku-function-types.gen.js'
|
||||||
|
import { TypedSecretService } from '../secrets/pikku-secrets.gen.js'
|
||||||
|
import { TypedVariablesService } from '../variables/pikku-variables.gen.js'
|
||||||
|
|
||||||
|
type AuthSingletonServices = Omit<SingletonServices, 'secrets' | 'variables'> & {
|
||||||
|
secrets: TypedSecretService
|
||||||
|
variables: TypedVariablesService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pikkuBetterAuth = <I extends BetterAuthInstance>(
|
||||||
|
factory: (services: AuthSingletonServices) => I | Promise<I>
|
||||||
|
): PikkuBetterAuthFactory<I> =>
|
||||||
|
_pikkuBetterAuth((services) =>
|
||||||
|
factory({
|
||||||
|
...services,
|
||||||
|
secrets: new TypedSecretService(services.secrets),
|
||||||
|
variables: new TypedVariablesService(services.variables),
|
||||||
|
} as AuthSingletonServices)
|
||||||
|
)
|
||||||
89
packages/functions/.pikku/channel/pikku-channels-map.gen.d.ts
vendored
Normal file
89
packages/functions/.pikku/channel/pikku-channels-map.gen.d.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* This provides the structure needed for TypeScript to be aware of channels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
|
||||||
|
|
||||||
|
import type { StubCall } from '/Users/yasser/git/pikku/fabric/templates/starter-template/node_modules/.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker.d.ts'
|
||||||
|
|
||||||
|
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
|
||||||
|
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
|
||||||
|
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
|
||||||
|
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
|
||||||
|
export type AuthHandlerOutput = Promise<void> | Promise<any>
|
||||||
|
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type DeleteAgentThreadOutput = { deleted: boolean; }
|
||||||
|
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadMessagesOutput = any[]
|
||||||
|
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadRunsOutput = any[]
|
||||||
|
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
|
||||||
|
export type GetAgentThreadsOutput = any[]
|
||||||
|
export type GetSessionInput = {}
|
||||||
|
export type GetSessionOutput = {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
|
||||||
|
export type GraphStarterOutput = { runId: string; }
|
||||||
|
export type PikkuConsoleGetSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleGetVariableInput = { variableId: string; }
|
||||||
|
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleHasSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
|
||||||
|
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetSecretOutput = { success: boolean; }
|
||||||
|
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetVariableOutput = { success: boolean; }
|
||||||
|
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
|
||||||
|
export type PikkuScenarioGetStubCallsOutput = StubCall[]
|
||||||
|
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
|
||||||
|
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
|
||||||
|
export type RealtimeEventStreamInput = { topic: string; }
|
||||||
|
export type RealtimeSubscribeInput = { topic: string; }
|
||||||
|
export type RealtimeUnsubscribeInput = { topic: string; }
|
||||||
|
export type RpcCallerInput = { rpcName: string; data?: unknown; }
|
||||||
|
export type SecretSchema_betterAuthSecret = string
|
||||||
|
export type SessionHealthScenarioOutput = { email: string; userId: string; }
|
||||||
|
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterOutput = { runId: string; }
|
||||||
|
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
|
||||||
|
|
||||||
|
interface ChannelHandler<I, O> {
|
||||||
|
input: I;
|
||||||
|
output: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelsMap = {
|
||||||
|
readonly 'events': {
|
||||||
|
readonly routes: {
|
||||||
|
readonly action: {
|
||||||
|
readonly subscribe: ChannelHandler<RealtimeSubscribeInput, never>,
|
||||||
|
readonly unsubscribe: ChannelHandler<RealtimeUnsubscribeInput, never>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readonly defaultMessage: never,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelDefaultHandlerOf<Channel extends keyof ChannelsMap> =
|
||||||
|
ChannelsMap[Channel]['defaultMessage'] extends { input: infer I; output: infer O }
|
||||||
|
? ChannelHandler<I, O>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ChannelWiringHandlerOf<
|
||||||
|
Channel extends keyof ChannelsMap,
|
||||||
|
Route extends keyof ChannelsMap[Channel]['routes'],
|
||||||
|
Method extends keyof ChannelsMap[Channel]['routes'][Route],
|
||||||
|
> =
|
||||||
|
ChannelsMap[Channel]['routes'][Route][Method] extends { input: infer I; output: infer O }
|
||||||
|
? ChannelHandler<I, O>
|
||||||
|
: never;
|
||||||
141
packages/functions/.pikku/db/classification-map.gen.d.ts
vendored
Normal file
141
packages/functions/.pikku/db/classification-map.gen.d.ts
vendored
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Generated by @pikku/cli — do not edit by hand.
|
||||||
|
// Run `pikku db migrate` to refresh.
|
||||||
|
// Use this type in db/classifications.ts:
|
||||||
|
// import type { DbClassificationMap } from './.pikku/db/classification-map.gen.d.ts'
|
||||||
|
// export const classifications = { ... } satisfies DbClassificationMap
|
||||||
|
|
||||||
|
export type ColumnEntry = {
|
||||||
|
/** Privacy level. Defaults to 'private' when omitted. */
|
||||||
|
security?: 'public' | 'private' | 'pii' | 'secret' | 'encrypted'
|
||||||
|
/** Anonymize strategy used by `pikku db anonymize`. */
|
||||||
|
classification?: 'fake:email' | 'fake:name' | 'hash' | 'keep'
|
||||||
|
/** Column kind override for codegen coercion + typing. */
|
||||||
|
kind?: 'date' | 'bool' | 'json' | 'uuid'
|
||||||
|
/** TypeScript type override, e.g. `string[]` or `MyJson`. Wins over `kind`. */
|
||||||
|
tsType?: string
|
||||||
|
/** Zod string-format validator (keeps the TS type as `string`). */
|
||||||
|
format?: 'email' | 'url' | 'emoji' | 'e164' | 'jwt' | 'cuid' | 'cuid2' | 'ulid' | 'nanoid' | 'base64' | 'base64url' | 'ipv4' | 'ipv6' | 'cidrv4' | 'cidrv6' | 'isoDate' | 'isoTime' | 'isoDatetime' | 'isoDuration'
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DbClassificationMap = {
|
||||||
|
"account": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"account_id": ColumnEntry
|
||||||
|
"provider_id": ColumnEntry
|
||||||
|
"user_id": ColumnEntry
|
||||||
|
"access_token": ColumnEntry
|
||||||
|
"refresh_token": ColumnEntry
|
||||||
|
"id_token": ColumnEntry
|
||||||
|
"access_token_expires_at": ColumnEntry
|
||||||
|
"refresh_token_expires_at": ColumnEntry
|
||||||
|
"scope": ColumnEntry
|
||||||
|
"password": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"ai_message": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"thread_id": ColumnEntry
|
||||||
|
"role": ColumnEntry
|
||||||
|
"content": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"ai_run": {
|
||||||
|
"run_id": ColumnEntry
|
||||||
|
"agent_name": ColumnEntry
|
||||||
|
"thread_id": ColumnEntry
|
||||||
|
"resource_id": ColumnEntry
|
||||||
|
"status": ColumnEntry
|
||||||
|
"error_message": ColumnEntry
|
||||||
|
"suspend_reason": ColumnEntry
|
||||||
|
"missing_rpcs": ColumnEntry
|
||||||
|
"usage_input_tokens": ColumnEntry
|
||||||
|
"usage_output_tokens": ColumnEntry
|
||||||
|
"usage_model": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"ai_threads": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"resource_id": ColumnEntry
|
||||||
|
"title": ColumnEntry
|
||||||
|
"metadata": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"ai_tool_call": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"thread_id": ColumnEntry
|
||||||
|
"message_id": ColumnEntry
|
||||||
|
"run_id": ColumnEntry
|
||||||
|
"tool_name": ColumnEntry
|
||||||
|
"args": ColumnEntry
|
||||||
|
"result": ColumnEntry
|
||||||
|
"approval_status": ColumnEntry
|
||||||
|
"approval_type": ColumnEntry
|
||||||
|
"agent_run_id": ColumnEntry
|
||||||
|
"display_tool_name": ColumnEntry
|
||||||
|
"display_args": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"ai_working_memory": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"scope": ColumnEntry
|
||||||
|
"data": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
}
|
||||||
|
"audit": {
|
||||||
|
"audit_id": ColumnEntry
|
||||||
|
"occurred_at": ColumnEntry
|
||||||
|
"type": ColumnEntry
|
||||||
|
"source": ColumnEntry
|
||||||
|
"outcome": ColumnEntry
|
||||||
|
"function_id": ColumnEntry
|
||||||
|
"wire_type": ColumnEntry
|
||||||
|
"trace_id": ColumnEntry
|
||||||
|
"transaction_id": ColumnEntry
|
||||||
|
"query_id": ColumnEntry
|
||||||
|
"actor_user_id": ColumnEntry
|
||||||
|
"actor_org_id": ColumnEntry
|
||||||
|
"tables": ColumnEntry
|
||||||
|
"changed_cols": ColumnEntry
|
||||||
|
"event": ColumnEntry
|
||||||
|
"old": ColumnEntry
|
||||||
|
"data": ColumnEntry
|
||||||
|
}
|
||||||
|
"session": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"expires_at": ColumnEntry
|
||||||
|
"token": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
"ip_address": ColumnEntry
|
||||||
|
"user_agent": ColumnEntry
|
||||||
|
"user_id": ColumnEntry
|
||||||
|
"impersonated_by": ColumnEntry
|
||||||
|
}
|
||||||
|
"user": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"name": ColumnEntry
|
||||||
|
"email": ColumnEntry
|
||||||
|
"email_verified": ColumnEntry
|
||||||
|
"image": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
"actor": ColumnEntry
|
||||||
|
"role": ColumnEntry
|
||||||
|
"banned": ColumnEntry
|
||||||
|
"ban_reason": ColumnEntry
|
||||||
|
"ban_expires": ColumnEntry
|
||||||
|
"fabric": ColumnEntry
|
||||||
|
}
|
||||||
|
"verification": {
|
||||||
|
"id": ColumnEntry
|
||||||
|
"identifier": ColumnEntry
|
||||||
|
"value": ColumnEntry
|
||||||
|
"expires_at": ColumnEntry
|
||||||
|
"created_at": ColumnEntry
|
||||||
|
"updated_at": ColumnEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/functions/.pikku/http/pikku-http-wirings-map.gen.d.ts
vendored
Normal file
148
packages/functions/.pikku/http/pikku-http-wirings-map.gen.d.ts
vendored
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* This provides the structure needed for typescript to be aware of routes and their return types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
|
||||||
|
import type { StreamWorkflowRunInput } from '.bun/@pikku+addon-console@0.12.26+8a6fcc78e6b267a5/node_modules/@pikku/addon-console/dist/.pikku/rpc/pikku-rpc-wirings-map.internal.gen'
|
||||||
|
import type { WorkflowRunStatus } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/wirings/workflow/workflow.types'
|
||||||
|
|
||||||
|
|
||||||
|
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
|
||||||
|
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
|
||||||
|
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
|
||||||
|
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
|
||||||
|
export type AuthHandlerOutput = Promise<void> | Promise<any>
|
||||||
|
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type DeleteAgentThreadOutput = { deleted: boolean; }
|
||||||
|
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadMessagesOutput = any[]
|
||||||
|
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadRunsOutput = any[]
|
||||||
|
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
|
||||||
|
export type GetAgentThreadsOutput = any[]
|
||||||
|
export type GetSessionInput = {}
|
||||||
|
export type GetSessionOutput = {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
|
||||||
|
export type GraphStarterOutput = { runId: string; }
|
||||||
|
export type PikkuConsoleGetSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleGetVariableInput = { variableId: string; }
|
||||||
|
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleHasSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
|
||||||
|
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetSecretOutput = { success: boolean; }
|
||||||
|
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetVariableOutput = { success: boolean; }
|
||||||
|
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
|
||||||
|
export type PikkuScenarioGetStubCallsOutput = StubCall[]
|
||||||
|
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
|
||||||
|
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
|
||||||
|
export type RealtimeEventStreamInput = { topic: string; }
|
||||||
|
export type RealtimeSubscribeInput = { topic: string; }
|
||||||
|
export type RealtimeUnsubscribeInput = { topic: string; }
|
||||||
|
export type RpcCallerInput = { rpcName: string; data?: unknown; }
|
||||||
|
export type SecretSchema_betterAuthSecret = string
|
||||||
|
export type SessionHealthScenarioOutput = { email: string; userId: string; }
|
||||||
|
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterOutput = { runId: string; }
|
||||||
|
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
|
||||||
|
|
||||||
|
// The '& {}' is a workaround for not directly refering to a type since it confuses typescript
|
||||||
|
export type AgentCallerInputParams = Pick<AgentCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentCallerInputBody = Omit<AgentCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentStreamCallerInputParams = Pick<AgentStreamCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentStreamCallerInputBody = Omit<AgentStreamCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentApproveCallerInputParams = Pick<AgentApproveCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentApproveCallerInputBody = Omit<AgentApproveCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentResumeCallerInputParams = Pick<AgentResumeCallerInput, 'agentName'> & {}
|
||||||
|
export type AgentResumeCallerInputBody = Omit<AgentResumeCallerInput, 'agentName'> & {}
|
||||||
|
export type StreamWorkflowRunInputParams = Pick<StreamWorkflowRunInput, 'runId'> & {}
|
||||||
|
export type StreamWorkflowRunInputBody = Omit<StreamWorkflowRunInput, 'runId'> & {}
|
||||||
|
export type RealtimeEventStreamInputParams = Pick<RealtimeEventStreamInput, 'topic'> & {}
|
||||||
|
export type RealtimeEventStreamInputBody = Omit<RealtimeEventStreamInput, 'topic'> & {}
|
||||||
|
export type RpcCallerInputParams = Pick<RpcCallerInput, 'rpcName'> & {}
|
||||||
|
export type RpcCallerInputBody = Omit<RpcCallerInput, 'rpcName'> & {}
|
||||||
|
export type WorkflowStarterInputParams = Pick<WorkflowStarterInput, 'workflowName'> & {}
|
||||||
|
export type WorkflowStarterInputBody = Omit<WorkflowStarterInput, 'workflowName'> & {}
|
||||||
|
export type WorkflowRunnerInputParams = Pick<WorkflowRunnerInput, 'workflowName'> & {}
|
||||||
|
export type WorkflowRunnerInputBody = Omit<WorkflowRunnerInput, 'workflowName'> & {}
|
||||||
|
export type WorkflowStatusCheckerInputParams = Pick<WorkflowStatusCheckerInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type WorkflowStatusCheckerInputBody = Omit<WorkflowStatusCheckerInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type WorkflowStatusStreamInputParams = Pick<WorkflowStatusStreamInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type WorkflowStatusStreamInputBody = Omit<WorkflowStatusStreamInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type WorkflowStatusStreamFullInputParams = Pick<WorkflowStatusStreamFullInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type WorkflowStatusStreamFullInputBody = Omit<WorkflowStatusStreamFullInput, 'workflowName' | 'runId'> & {}
|
||||||
|
export type GraphStarterInputParams = Pick<GraphStarterInput, 'workflowName' | 'nodeId'> & {}
|
||||||
|
export type GraphStarterInputBody = Omit<GraphStarterInput, 'workflowName' | 'nodeId'> & {}
|
||||||
|
|
||||||
|
interface HTTPWiringHandler<I, O> {
|
||||||
|
input: I;
|
||||||
|
output: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HTTPWiringsMap = {
|
||||||
|
readonly '/api/auth{/*splat}': {
|
||||||
|
readonly GET: HTTPWiringHandler<null, AuthHandlerOutput>,
|
||||||
|
readonly POST: HTTPWiringHandler<null, AuthHandlerOutput>,
|
||||||
|
},
|
||||||
|
readonly '/workflow-run/:runId/stream': {
|
||||||
|
readonly GET: HTTPWiringHandler<StreamWorkflowRunInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/events/:topic': {
|
||||||
|
readonly GET: HTTPWiringHandler<RealtimeEventStreamInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/status/:runId': {
|
||||||
|
readonly GET: HTTPWiringHandler<WorkflowStatusCheckerInput, WorkflowRunStatus>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/status/:runId/stream': {
|
||||||
|
readonly GET: HTTPWiringHandler<WorkflowStatusStreamInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/status/:runId/stream/full': {
|
||||||
|
readonly GET: HTTPWiringHandler<WorkflowStatusStreamFullInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/rpc/agent/:agentName': {
|
||||||
|
readonly POST: HTTPWiringHandler<AgentCallerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/rpc/agent/:agentName/stream': {
|
||||||
|
readonly POST: HTTPWiringHandler<AgentStreamCallerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/rpc/agent/:agentName/approve': {
|
||||||
|
readonly POST: HTTPWiringHandler<AgentApproveCallerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/rpc/agent/:agentName/resume': {
|
||||||
|
readonly POST: HTTPWiringHandler<AgentResumeCallerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/rpc/:rpcName': {
|
||||||
|
readonly POST: HTTPWiringHandler<RpcCallerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/start': {
|
||||||
|
readonly POST: HTTPWiringHandler<WorkflowStarterInput, WorkflowStarterOutput>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/run': {
|
||||||
|
readonly POST: HTTPWiringHandler<WorkflowRunnerInput, null>,
|
||||||
|
},
|
||||||
|
readonly '/workflow/:workflowName/graph/:nodeId': {
|
||||||
|
readonly POST: HTTPWiringHandler<GraphStarterInput, GraphStarterOutput>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HTTPWiringHandlerOf<HTTPWiring extends keyof HTTPWiringsMap, Method extends keyof HTTPWiringsMap[HTTPWiring]> =
|
||||||
|
HTTPWiringsMap[HTTPWiring][Method] extends { input: infer I; output: infer O }
|
||||||
|
? HTTPWiringHandler<I, O>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HTTPWiringsWithMethod<Method extends string> = {
|
||||||
|
[HTTPWiring in keyof HTTPWiringsMap]: Method extends keyof HTTPWiringsMap[HTTPWiring] ? HTTPWiring : never;
|
||||||
|
}[keyof HTTPWiringsMap];
|
||||||
|
|
||||||
168
packages/functions/.pikku/rpc/pikku-rpc-wirings-map.gen.d.ts
vendored
Normal file
168
packages/functions/.pikku/rpc/pikku-rpc-wirings-map.gen.d.ts
vendored
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* This provides the structure needed for typescript to be aware of RPCs and their return types
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
|
||||||
|
import type { FunctionCoverageReport } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/v8-coverage-service'
|
||||||
|
|
||||||
|
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
|
||||||
|
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
|
||||||
|
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
|
||||||
|
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
|
||||||
|
export type AuthHandlerOutput = Promise<void> | Promise<any>
|
||||||
|
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type DeleteAgentThreadOutput = { deleted: boolean; }
|
||||||
|
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadMessagesOutput = any[]
|
||||||
|
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadRunsOutput = any[]
|
||||||
|
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
|
||||||
|
export type GetAgentThreadsOutput = any[]
|
||||||
|
export type GetSessionInput = {}
|
||||||
|
export type GetSessionOutput = {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
|
||||||
|
export type GraphStarterOutput = { runId: string; }
|
||||||
|
export type PikkuConsoleGetSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleGetVariableInput = { variableId: string; }
|
||||||
|
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleHasSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
|
||||||
|
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetSecretOutput = { success: boolean; }
|
||||||
|
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetVariableOutput = { success: boolean; }
|
||||||
|
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
|
||||||
|
export type PikkuScenarioGetStubCallsOutput = StubCall[]
|
||||||
|
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
|
||||||
|
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
|
||||||
|
export type RealtimeEventStreamInput = { topic: string; }
|
||||||
|
export type RealtimeSubscribeInput = { topic: string; }
|
||||||
|
export type RealtimeUnsubscribeInput = { topic: string; }
|
||||||
|
export type RpcCallerInput = { rpcName: string; data?: unknown; }
|
||||||
|
export type SecretSchema_betterAuthSecret = string
|
||||||
|
export type SessionHealthScenarioOutput = { email: string; userId: string; }
|
||||||
|
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterOutput = { runId: string; }
|
||||||
|
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
|
||||||
|
|
||||||
|
interface RPCHandler<I, O> {
|
||||||
|
input: I;
|
||||||
|
output: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPCMap = {
|
||||||
|
readonly 'getSession': RPCHandler<GetSessionInput, GetSessionOutput>,
|
||||||
|
readonly 'getAgentThreads': RPCHandler<GetAgentThreadsInput, GetAgentThreadsOutput>,
|
||||||
|
readonly 'getAgentThreadMessages': RPCHandler<GetAgentThreadMessagesInput, GetAgentThreadMessagesOutput>,
|
||||||
|
readonly 'getAgentThreadRuns': RPCHandler<GetAgentThreadRunsInput, GetAgentThreadRunsOutput>,
|
||||||
|
readonly 'deleteAgentThread': RPCHandler<DeleteAgentThreadInput, DeleteAgentThreadOutput>,
|
||||||
|
readonly 'pikkuConsoleSetSecret': RPCHandler<PikkuConsoleSetSecretInput, PikkuConsoleSetSecretOutput>,
|
||||||
|
readonly 'pikkuConsoleGetVariable': RPCHandler<PikkuConsoleGetVariableInput, PikkuConsoleGetVariableOutput>,
|
||||||
|
readonly 'pikkuConsoleSetVariable': RPCHandler<PikkuConsoleSetVariableInput, PikkuConsoleSetVariableOutput>,
|
||||||
|
readonly 'pikkuConsoleHasSecret': RPCHandler<PikkuConsoleHasSecretInput, PikkuConsoleHasSecretOutput>,
|
||||||
|
readonly 'pikkuConsoleGetSecret': RPCHandler<PikkuConsoleGetSecretInput, PikkuConsoleGetSecretOutput>,
|
||||||
|
readonly 'pikkuScenarioTakeLiveCoverage': RPCHandler<null, FunctionCoverageReport>,
|
||||||
|
readonly 'pikkuScenarioResetLiveCoverage': RPCHandler<null, PikkuScenarioResetLiveCoverageOutput>,
|
||||||
|
readonly 'pikkuScenarioResetStubs': RPCHandler<null, PikkuScenarioResetStubsOutput>,
|
||||||
|
readonly 'pikkuScenarioGetStubCalls': RPCHandler<PikkuScenarioGetStubCallsInput, PikkuScenarioGetStubCallsOutput>,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Addon package RPC maps
|
||||||
|
import type { RPCMap as ConsoleRPCMap } from '@pikku/addon-console/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js'
|
||||||
|
|
||||||
|
|
||||||
|
// Utility type to prefix keys with namespace (skips 'any' to prevent type poisoning)
|
||||||
|
type PrefixKeys<T, Prefix extends string> = unknown extends T ? {} : {
|
||||||
|
[K in keyof T as `${Prefix}:${string & K}`]: T[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all RPC maps with namespace prefixes
|
||||||
|
export type FlattenedRPCMap =
|
||||||
|
RPCMap & PrefixKeys<ConsoleRPCMap, 'console'>
|
||||||
|
|
||||||
|
type IsAny<T> = 0 extends (1 & T) ? true : false
|
||||||
|
type IsVoidishInput<T> = IsAny<T> extends true
|
||||||
|
? false
|
||||||
|
: [T] extends [void | null | undefined]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
|
||||||
|
|
||||||
|
export type RPCInvoke = <Name extends keyof FlattenedRPCMap>(
|
||||||
|
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
|
||||||
|
? [name: Name]
|
||||||
|
: [name: Name, data: FlattenedRPCMap[Name]['input']]
|
||||||
|
) => Promise<FlattenedRPCMap[Name]['output']>
|
||||||
|
|
||||||
|
export type RPCRemote = <Name extends keyof FlattenedRPCMap>(
|
||||||
|
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
|
||||||
|
? [name: Name]
|
||||||
|
: [name: Name, data: FlattenedRPCMap[Name]['input']]
|
||||||
|
) => Promise<FlattenedRPCMap[Name]['output']>
|
||||||
|
|
||||||
|
import type { FlattenedWorkflowMap } from '../workflow/pikku-workflow-map.gen.d.js'
|
||||||
|
|
||||||
|
import type { AgentMap } from '../agent/pikku-agent-map.gen.d.js'
|
||||||
|
|
||||||
|
// Addon package Agent maps
|
||||||
|
import type { AgentMap as ConsoleAgentMap } from '@pikku/addon-console/.pikku/agent/pikku-agent-map.gen.d.js'
|
||||||
|
|
||||||
|
|
||||||
|
type FlattenedAgentMap =
|
||||||
|
AgentMap & PrefixKeys<ConsoleAgentMap, 'console'>
|
||||||
|
|
||||||
|
|
||||||
|
import type { PikkuRPC } from '@pikku/core/rpc'
|
||||||
|
|
||||||
|
interface AIAgentInput {
|
||||||
|
message: string
|
||||||
|
threadId: string
|
||||||
|
resourceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypedStartWorkflow = <Name extends keyof FlattenedWorkflowMap>(
|
||||||
|
name: Name,
|
||||||
|
input: FlattenedWorkflowMap[Name]['input'],
|
||||||
|
options?: { startNode?: string }
|
||||||
|
) => Promise<{ runId: string }>
|
||||||
|
|
||||||
|
export type TypedRunWorkflow = <Name extends keyof FlattenedWorkflowMap>(
|
||||||
|
name: Name,
|
||||||
|
input: FlattenedWorkflowMap[Name]['input']
|
||||||
|
) => Promise<FlattenedWorkflowMap[Name]['output']>
|
||||||
|
|
||||||
|
export type TypedWorkflowStatus = (
|
||||||
|
workflowName: string,
|
||||||
|
runId: string
|
||||||
|
) => Promise<{ id: string; status: 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'; output?: unknown; error?: { message?: string } }>
|
||||||
|
|
||||||
|
type TypedAgentRun = [keyof FlattenedAgentMap] extends [never]
|
||||||
|
? (name: string, input: AIAgentInput) => Promise<any>
|
||||||
|
: <Name extends keyof FlattenedAgentMap>(
|
||||||
|
name: Name,
|
||||||
|
input: AIAgentInput
|
||||||
|
) => Promise<{ runId: string; result: FlattenedAgentMap[Name]['output']; usage: { inputTokens: number; outputTokens: number } }>
|
||||||
|
|
||||||
|
type TypedAgentStream = [keyof FlattenedAgentMap] extends [never]
|
||||||
|
? (name: string, input: AIAgentInput, options?: { requiresToolApproval?: 'all' | 'explicit' | false }) => Promise<void>
|
||||||
|
: <Name extends keyof FlattenedAgentMap>(
|
||||||
|
name: Name,
|
||||||
|
input: AIAgentInput,
|
||||||
|
options?: { requiresToolApproval?: 'all' | 'explicit' | false }
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
export type TypedPikkuRPC = PikkuRPC<RPCInvoke, RPCRemote, TypedStartWorkflow, TypedAgentRun, TypedAgentStream>
|
||||||
|
|
||||||
185
packages/functions/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.d.ts
vendored
Normal file
185
packages/functions/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.d.ts
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* This file was generated by @pikku/cli@0.12.76
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* This provides the structure needed for typescript to be aware of RPCs and their return types
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { StubCall } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/stub-tracker'
|
||||||
|
import type { FunctionCoverageReport } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/services/v8-coverage-service'
|
||||||
|
import type { WorkflowRunStatus } from '.bun/@pikku+core@0.12.57/node_modules/@pikku/core/dist/wirings/workflow/workflow.types'
|
||||||
|
|
||||||
|
export type AgentApproveCallerInput = { agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean; }[]; }
|
||||||
|
export type AgentCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; }
|
||||||
|
export type AgentResumeCallerInput = { agentName: string; runId: string; toolCallId: string; approved: boolean; }
|
||||||
|
export type AgentStreamCallerInput = { agentName: string; message: string; threadId: string; resourceId: string; context?: string | undefined; }
|
||||||
|
export type AuthHandlerOutput = Promise<void> | Promise<any>
|
||||||
|
export type DeleteAgentThreadInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type DeleteAgentThreadOutput = { deleted: boolean; }
|
||||||
|
export type GetAgentThreadMessagesInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadMessagesOutput = any[]
|
||||||
|
export type GetAgentThreadRunsInput = { threadId: string; resourceId?: string | undefined; }
|
||||||
|
export type GetAgentThreadRunsOutput = any[]
|
||||||
|
export type GetAgentThreadsInput = { agentName?: string | undefined; resourceId?: string | undefined; limit?: number | undefined; offset?: number | undefined; }
|
||||||
|
export type GetAgentThreadsOutput = any[]
|
||||||
|
export type GetSessionInput = {}
|
||||||
|
export type GetSessionOutput = {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export type GraphStarterInput = { workflowName: string; nodeId: string; data?: unknown; }
|
||||||
|
export type GraphStarterOutput = { runId: string; }
|
||||||
|
export type PikkuConsoleGetSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleGetSecretOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleGetVariableInput = { variableId: string; }
|
||||||
|
export type PikkuConsoleGetVariableOutput = { exists: boolean; value: unknown; }
|
||||||
|
export type PikkuConsoleHasSecretInput = { secretId: string; }
|
||||||
|
export type PikkuConsoleHasSecretOutput = { exists: boolean; }
|
||||||
|
export type PikkuConsoleSetSecretInput = { secretId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetSecretOutput = { success: boolean; }
|
||||||
|
export type PikkuConsoleSetVariableInput = { variableId: string; value: unknown; }
|
||||||
|
export type PikkuConsoleSetVariableOutput = { success: boolean; }
|
||||||
|
export type PikkuScenarioGetStubCallsInput = { service?: string | undefined; }
|
||||||
|
export type PikkuScenarioGetStubCallsOutput = StubCall[]
|
||||||
|
export type PikkuScenarioResetLiveCoverageOutput = { enabled: boolean; }
|
||||||
|
export type PikkuScenarioResetStubsOutput = { enabled: boolean; }
|
||||||
|
export type RealtimeEventStreamInput = { topic: string; }
|
||||||
|
export type RealtimeSubscribeInput = { topic: string; }
|
||||||
|
export type RealtimeUnsubscribeInput = { topic: string; }
|
||||||
|
export type RpcCallerInput = { rpcName: string; data?: unknown; }
|
||||||
|
export type SecretSchema_betterAuthSecret = string
|
||||||
|
export type SessionHealthScenarioOutput = { email: string; userId: string; }
|
||||||
|
export type WorkflowRunnerInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterInput = { workflowName: string; data?: unknown; }
|
||||||
|
export type WorkflowStarterOutput = { runId: string; }
|
||||||
|
export type WorkflowStatusCheckerInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamFullInput = { workflowName: string; runId: string; }
|
||||||
|
export type WorkflowStatusStreamInput = { workflowName: string; runId: string; }
|
||||||
|
|
||||||
|
interface RPCHandler<I, O> {
|
||||||
|
input: I;
|
||||||
|
output: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPCMap = {
|
||||||
|
readonly 'getSession': RPCHandler<GetSessionInput, GetSessionOutput>,
|
||||||
|
readonly 'sessionHealthScenario': RPCHandler<null, SessionHealthScenarioOutput>,
|
||||||
|
readonly 'agentCaller': RPCHandler<AgentCallerInput, null>,
|
||||||
|
readonly 'agentStreamCaller': RPCHandler<AgentStreamCallerInput, null>,
|
||||||
|
readonly 'agentApproveCaller': RPCHandler<AgentApproveCallerInput, null>,
|
||||||
|
readonly 'agentResumeCaller': RPCHandler<AgentResumeCallerInput, null>,
|
||||||
|
readonly 'getAgentThreads': RPCHandler<GetAgentThreadsInput, GetAgentThreadsOutput>,
|
||||||
|
readonly 'getAgentThreadMessages': RPCHandler<GetAgentThreadMessagesInput, GetAgentThreadMessagesOutput>,
|
||||||
|
readonly 'getAgentThreadRuns': RPCHandler<GetAgentThreadRunsInput, GetAgentThreadRunsOutput>,
|
||||||
|
readonly 'deleteAgentThread': RPCHandler<DeleteAgentThreadInput, DeleteAgentThreadOutput>,
|
||||||
|
readonly 'authHandler': RPCHandler<null, AuthHandlerOutput>,
|
||||||
|
readonly 'pikkuConsoleSetSecret': RPCHandler<PikkuConsoleSetSecretInput, PikkuConsoleSetSecretOutput>,
|
||||||
|
readonly 'pikkuConsoleGetVariable': RPCHandler<PikkuConsoleGetVariableInput, PikkuConsoleGetVariableOutput>,
|
||||||
|
readonly 'pikkuConsoleSetVariable': RPCHandler<PikkuConsoleSetVariableInput, PikkuConsoleSetVariableOutput>,
|
||||||
|
readonly 'pikkuConsoleHasSecret': RPCHandler<PikkuConsoleHasSecretInput, PikkuConsoleHasSecretOutput>,
|
||||||
|
readonly 'pikkuConsoleGetSecret': RPCHandler<PikkuConsoleGetSecretInput, PikkuConsoleGetSecretOutput>,
|
||||||
|
readonly 'realtimeSubscribe': RPCHandler<RealtimeSubscribeInput, null>,
|
||||||
|
readonly 'realtimeUnsubscribe': RPCHandler<RealtimeUnsubscribeInput, null>,
|
||||||
|
readonly 'realtimeEventStream': RPCHandler<RealtimeEventStreamInput, null>,
|
||||||
|
readonly 'rpcCaller': RPCHandler<RpcCallerInput, null>,
|
||||||
|
readonly 'pikkuScenarioTakeLiveCoverage': RPCHandler<null, FunctionCoverageReport>,
|
||||||
|
readonly 'pikkuScenarioResetLiveCoverage': RPCHandler<null, PikkuScenarioResetLiveCoverageOutput>,
|
||||||
|
readonly 'pikkuScenarioResetStubs': RPCHandler<null, PikkuScenarioResetStubsOutput>,
|
||||||
|
readonly 'pikkuScenarioGetStubCalls': RPCHandler<PikkuScenarioGetStubCallsInput, PikkuScenarioGetStubCallsOutput>,
|
||||||
|
readonly 'workflowStarter': RPCHandler<WorkflowStarterInput, WorkflowStarterOutput>,
|
||||||
|
readonly 'workflowRunner': RPCHandler<WorkflowRunnerInput, null>,
|
||||||
|
readonly 'workflowStatusChecker': RPCHandler<WorkflowStatusCheckerInput, WorkflowRunStatus>,
|
||||||
|
readonly 'workflowStatusStream': RPCHandler<WorkflowStatusStreamInput, null>,
|
||||||
|
readonly 'workflowStatusStreamFull': RPCHandler<WorkflowStatusStreamFullInput, null>,
|
||||||
|
readonly 'graphStarter': RPCHandler<GraphStarterInput, GraphStarterOutput>,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Addon package RPC maps
|
||||||
|
import type { RPCMap as ConsoleRPCMap } from '@pikku/addon-console/.pikku/rpc/pikku-rpc-wirings-map.internal.gen.js'
|
||||||
|
|
||||||
|
|
||||||
|
// Utility type to prefix keys with namespace (skips 'any' to prevent type poisoning)
|
||||||
|
type PrefixKeys<T, Prefix extends string> = unknown extends T ? {} : {
|
||||||
|
[K in keyof T as `${Prefix}:${string & K}`]: T[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all RPC maps with namespace prefixes
|
||||||
|
export type FlattenedRPCMap =
|
||||||
|
RPCMap & PrefixKeys<ConsoleRPCMap, 'console'>
|
||||||
|
|
||||||
|
type IsAny<T> = 0 extends (1 & T) ? true : false
|
||||||
|
type IsVoidishInput<T> = IsAny<T> extends true
|
||||||
|
? false
|
||||||
|
: [T] extends [void | null | undefined]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
|
||||||
|
|
||||||
|
export type RPCInvoke = <Name extends keyof FlattenedRPCMap>(
|
||||||
|
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
|
||||||
|
? [name: Name]
|
||||||
|
: [name: Name, data: FlattenedRPCMap[Name]['input']]
|
||||||
|
) => Promise<FlattenedRPCMap[Name]['output']>
|
||||||
|
|
||||||
|
export type RPCRemote = <Name extends keyof FlattenedRPCMap>(
|
||||||
|
...args: IsVoidishInput<FlattenedRPCMap[Name]['input']> extends true
|
||||||
|
? [name: Name]
|
||||||
|
: [name: Name, data: FlattenedRPCMap[Name]['input']]
|
||||||
|
) => Promise<FlattenedRPCMap[Name]['output']>
|
||||||
|
|
||||||
|
import type { FlattenedWorkflowMap } from '../workflow/pikku-workflow-map.gen.d.js'
|
||||||
|
|
||||||
|
import type { AgentMap } from '../agent/pikku-agent-map.gen.d.js'
|
||||||
|
|
||||||
|
// Addon package Agent maps
|
||||||
|
import type { AgentMap as ConsoleAgentMap } from '@pikku/addon-console/.pikku/agent/pikku-agent-map.gen.d.js'
|
||||||
|
|
||||||
|
|
||||||
|
type FlattenedAgentMap =
|
||||||
|
AgentMap & PrefixKeys<ConsoleAgentMap, 'console'>
|
||||||
|
|
||||||
|
|
||||||
|
import type { PikkuRPC } from '@pikku/core/rpc'
|
||||||
|
|
||||||
|
interface AIAgentInput {
|
||||||
|
message: string
|
||||||
|
threadId: string
|
||||||
|
resourceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypedStartWorkflow = <Name extends keyof FlattenedWorkflowMap>(
|
||||||
|
name: Name,
|
||||||
|
input: FlattenedWorkflowMap[Name]['input'],
|
||||||
|
options?: { startNode?: string }
|
||||||
|
) => Promise<{ runId: string }>
|
||||||
|
|
||||||
|
export type TypedRunWorkflow = <Name extends keyof FlattenedWorkflowMap>(
|
||||||
|
name: Name,
|
||||||
|
input: FlattenedWorkflowMap[Name]['input']
|
||||||
|
) => Promise<FlattenedWorkflowMap[Name]['output']>
|
||||||
|
|
||||||
|
export type TypedWorkflowStatus = (
|
||||||
|
workflowName: string,
|
||||||
|
runId: string
|
||||||
|
) => Promise<{ id: string; status: 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled'; output?: unknown; error?: { message?: string } }>
|
||||||
|
|
||||||
|
type TypedAgentRun = [keyof FlattenedAgentMap] extends [never]
|
||||||
|
? (name: string, input: AIAgentInput) => Promise<any>
|
||||||
|
: <Name extends keyof FlattenedAgentMap>(
|
||||||
|
name: Name,
|
||||||
|
input: AIAgentInput
|
||||||
|
) => Promise<{ runId: string; result: FlattenedAgentMap[Name]['output']; usage: { inputTokens: number; outputTokens: number } }>
|
||||||
|
|
||||||
|
type TypedAgentStream = [keyof FlattenedAgentMap] extends [never]
|
||||||
|
? (name: string, input: AIAgentInput, options?: { requiresToolApproval?: 'all' | 'explicit' | false }) => Promise<void>
|
||||||
|
: <Name extends keyof FlattenedAgentMap>(
|
||||||
|
name: Name,
|
||||||
|
input: AIAgentInput,
|
||||||
|
options?: { requiresToolApproval?: 'all' | 'explicit' | false }
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
export type TypedPikkuRPC = PikkuRPC<RPCInvoke, RPCRemote, TypedStartWorkflow, TypedAgentRun, TypedAgentStream>
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"runId":{"type":"string"},"approvals":{"type":"array","items":{"type":"object","properties":{"toolCallId":{"type":"string"},"approved":{"type":"boolean"}},"required":["toolCallId","approved"],"additionalProperties":false}}},"required":["agentName","runId","approvals"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"message":{"type":"string"},"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["agentName","message","threadId","resourceId"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"runId":{"type":"string"},"toolCallId":{"type":"string"},"approved":{"type":"boolean"}},"required":["agentName","runId","toolCallId","approved"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"agentName":{"type":"string"},"message":{"type":"string"},"threadId":{"type":"string"},"resourceId":{"type":"string"},"context":{"type":"string"}},"required":["agentName","message","threadId","resourceId"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"null"},{}],"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["threadId"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"],"additionalProperties":false,"definitions":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"generatedAt":{"type":"string"},"summary":{"type":"object","properties":{"total":{"type":"number"},"covered":{"type":"number"},"partial":{"type":"number"},"uncovered":{"type":"number"},"unknown":{"type":"number"},"overallRatio":{"type":"number"}},"required":["total","covered","partial","uncovered","unknown","overallRatio"],"additionalProperties":false},"functions":{"type":"array","items":{"$ref":"#/definitions/FunctionCoverageEntry"}}},"required":["generatedAt","summary","functions"],"additionalProperties":false,"definitions":{"FunctionCoverageEntry":{"type":"object","properties":{"name":{"type":"string"},"sourceFile":{"type":"string"},"exposed":{"type":"boolean"},"description":{"type":["string","null"]},"coveredLines":{"type":"number"},"totalLines":{"type":"number"},"missedLines":{"type":"array","items":{"type":"number"}},"ratio":{"type":"number"},"status":{"$ref":"#/definitions/CoverageStatus"}},"required":["name","sourceFile","exposed","description","coveredLines","totalLines","missedLines","ratio","status"],"additionalProperties":false},"CoverageStatus":{"type":"string","enum":["covered","partial","uncovered","unknown"]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"threadId":{"type":"string"},"resourceId":{"type":"string"}},"required":["threadId"],"additionalProperties":false,"definitions":{}}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user