Template
1
0

feat: checkpoint

This commit is contained in:
2025-11-23 22:57:43 +01:00
parent 7df57522d2
commit 5d45e273ee
160 changed files with 10160 additions and 1476 deletions

9
.bruno/bruno.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Valkyr",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,3 @@
vars {
url: http://localhost:8370/api/v1
}

19
.bruno/identity/Get.bru Normal file
View File

@@ -0,0 +1,19 @@
meta {
name: Get
type: http
seq: 2
}
get {
url: {{url}}/identity/:id
body: none
auth: inherit
}
params:path {
id:
}
settings {
encodeUrl: true
}

32
.bruno/identity/Roles.bru Normal file
View File

@@ -0,0 +1,32 @@
meta {
name: Roles
type: http
seq: 4
}
put {
url: {{url}}/identity/:id/roles
body: json
auth: inherit
}
params:path {
id:
}
body:json {
[
{
"type": "add",
"roles": []
},
{
"type": "remove",
"roles": []
}
]
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,43 @@
meta {
name: Update
type: http
seq: 4
}
put {
url: {{url}}/identity/:id
body: json
auth: inherit
}
params:path {
id:
}
body:json {
[
{
"type": "add",
"key": "",
"value": ""
},
{
"type": "push",
"key": "",
"values": ""
},
{
"type": "pop",
"key": "",
"values": ""
},
{
"type": "remove",
"key": ""
}
]
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Identity
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,29 @@
meta {
name: Code
type: http
seq: 3
}
post {
url: {{url}}/identity/login/code
body: json
auth: inherit
}
body:json {
{
"email": "john.doe@fixture.none",
"otp": ""
}
}
script:post-response {
const cookies = res.getHeader('set-cookie');
if (cookies) {
bru.setVar("cookie", cookies.join('; '));
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,21 @@
meta {
name: Email
type: http
seq: 2
}
post {
url: {{url}}/identity/login/email
body: json
auth: inherit
}
body:json {
{
"email": "john.doe@fixture.none"
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Login
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,21 @@
meta {
name: Sudo
type: http
seq: 1
}
post {
url: {{url}}/identities/login/sudo
body: json
auth: inherit
}
body:json {
{
"email": "john.doe@fixture.none"
}
}
settings {
encodeUrl: true
}

15
.bruno/identity/me.bru Normal file
View File

@@ -0,0 +1,15 @@
meta {
name: Me
type: http
seq: 1
}
get {
url: {{url}}/identity/me
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,21 @@
meta {
name: Create
type: http
seq: 1
}
post {
url: {{url}}/workspace
body: json
auth: inherit
}
body:json {
{
"name": ""
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Workspace
seq: 2
}
auth {
mode: inherit
}

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.volumes
node_modules node_modules

17
.vscode/settings.json vendored
View File

@@ -1,10 +1,19 @@
{ {
"biome.enabled": true,
"deno.enable": true, "deno.enable": true,
"deno.lint": false,
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
}, },
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
} }

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM denoland/deno:2.5.1
ENV TZ=UTC
ENV PORT=8370
EXPOSE 8370
WORKDIR /app
COPY api/ ./api/
COPY relay/ ./relay/
COPY .npmrc .
COPY deno-docker.json ./deno.json
RUN deno install --allow-scripts
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]

16
LICENSE
View File

@@ -1,16 +0,0 @@
MIT License
Copyright 2025 Christoffer Rødvik.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form.
Use as part of a larger work, integrated product, or service is allowed.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,87 +1 @@
<p align="center"> # Boilerplate
<img src="https://user-images.githubusercontent.com/1998130/229430454-ca0f2811-d874-4314-b13d-c558de8eec7e.svg" />
</p>
# Relay
Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints.
## Quick Start
For this quick start guide we assume the following project setup:
```
api/
relay/
web/
```
### Relay
First we want to set up our relay space, from the structure above lets start by defining our route.
```ts
import { route } from "@valkyr/relay";
import z from "zod";
export default route
.post("/users")
.body(
z.object({
name: z.string(),
email: z.string().check(z.email()),
})
)
.response(z.string());
```
After creating our first route we mount it onto our relay instance.
```ts
import { Relay } from "@valkyr/relay";
import route from "./path/to/route.ts";
export const relay = new Relay([
route
]);
```
We have now finished defining our initial relay setup which we can now utilize in our `api` and `web` spaces.
### API
To be able to successfully execute our user create route we need to attach a handler in our `api`. Lets start off by defining our handler.
```ts
import { UnprocessableContentError } from "@valkyr/relay";
import { relay } from "~project/relay/mod.ts";
relay
.route("POST", "/users")
.handle(async ({ name, email }) => {
const user = await db.users.insert({ name, email });
if (user === undefined) {
return new UnprocessableContentError();
}
return user.id;
});
```
We now have a `POST` handler for the `/users` path.
### Web
Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application.
```ts
import { relay } from "~project/relay/mod.ts"
const userId = await relay.post("/users", {
name: "John Doe",
email: "john.doe@fixture.none"
});
console.log(userId); // => string
```

View File

@@ -1,16 +0,0 @@
import { RequestInput } from "../libraries/relay.ts";
import { RelayAdapter } from "../mod.ts";
export const http: RelayAdapter = {
async fetch({ method, url, search, body }: RequestInput) {
const res = await fetch(`${url}${search}`, { method, body });
const data = await res.text();
if (res.status >= 400) {
throw new Error(data);
}
if (res.headers.get("content-type")?.includes("json")) {
return JSON.parse(data);
}
return data;
},
};

12
api/config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { getEnvironmentVariable } from "@platform/config";
import z from "zod";
export const config = {
name: "@valkyr/boilerplate",
host: getEnvironmentVariable({ key: "API_HOST", type: z.ipv4(), fallback: "0.0.0.0" }),
port: getEnvironmentVariable({
key: "API_PORT",
type: z.coerce.number(),
fallback: "8370",
}),
};

12
api/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"private": true,
"scripts": {
"start": "deno --allow-all --watch-hmr=routes/ server.ts"
},
"dependencies": {
"@modules/identity": "workspace:*",
"@module/workspace": "workspace:*",
"@platform/config": "workspace:*",
"zod": "4.1.12"
}
}

73
api/server.ts Normal file
View File

@@ -0,0 +1,73 @@
import { logger } from "@platform/logger";
import { context } from "@platform/relay";
import { Api } from "@platform/server/api.ts";
import server from "@platform/server/server.ts";
import socket from "@platform/socket/server.ts";
import { storage } from "@platform/storage";
import { config } from "./config.ts";
import session from "./session.ts";
const log = logger.prefix("Server");
/*
|--------------------------------------------------------------------------------
| Bootstrap
|--------------------------------------------------------------------------------
*/
// ### Platform
await server.bootstrap();
await socket.bootstrap();
await session.bootstrap();
// ### Modules
// await workspace.bootstrap();
/*
|--------------------------------------------------------------------------------
| Service
|--------------------------------------------------------------------------------
*/
const api = new Api([
/*...identity.routes, ...workspace.routes*/
]);
/*
|--------------------------------------------------------------------------------
| Server
|--------------------------------------------------------------------------------
*/
Deno.serve(
{
port: config.port,
hostname: config.host,
onListen({ port, hostname }) {
logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
},
},
async (request) =>
storage.run({}, async () => {
const url = new URL(request.url);
// ### Storage Context
// Resolve storage context for all dependent modules.
await server.resolve(request);
await socket.resolve();
await session.resolve(request);
// ### Fetch
// Execute fetch against the api instance.
return api.fetch(request).finally(() => {
log.info(
`${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`,
);
});
}),
);

111
api/session.ts Normal file
View File

@@ -0,0 +1,111 @@
import { context, UnauthorizedError } from "@platform/relay";
import { storage } from "@platform/storage";
const IDENTITY_RESOLVE_HEADER = "x-identity-resolver";
export default {
bootstrap: async () => {
bootstrapSessionContext();
},
resolve: async (request: Request) => {
await resolvePrincipalSession(request);
},
};
function bootstrapSessionContext() {
Object.defineProperties(context, {
/**
* TODO ...
*/
isAuthenticated: {
get() {
return storage.getStore()?.principal !== undefined;
},
},
/**
* TODO ...
*/
session: {
get() {
const session = storage.getStore()?.session;
if (session === undefined) {
throw new UnauthorizedError();
}
return session;
},
},
/**
* TODO ...
*/
principal: {
get() {
const principal = storage.getStore()?.principal;
if (principal === undefined) {
throw new UnauthorizedError();
}
return principal;
},
},
/**
* TODO ...
*/
access: {
get() {
const access = storage.getStore()?.access;
if (access === undefined) {
throw new UnauthorizedError();
}
return access;
},
},
});
}
async function resolvePrincipalSession(request: Request) {
// ### Resolver
// Check if the incoming request is tagged as a resolver check.
// If it is a resolver we break out of the session resolution
// to avoid an infinite resolution loop.
const isResolver = request.headers.get(IDENTITY_RESOLVE_HEADER) !== null;
if (isResolver) {
return;
}
// ### Cookie
// Check for the existence of cookie to pass onto the session
// resolver.
const cookie = request.headers.get("cookie");
if (cookie === null) {
return;
}
// ### Session
// Fetch session from identity module and tag it as a resolution
// call so it can break out of a resolution loop.
const session = await getPrincipalSession({
headers: new Headers({
cookie,
[IDENTITY_RESOLVE_HEADER]: "true",
}),
});
// ### Populate Context
// On successfull resolution we build the request identity context.
if (session !== undefined) {
const context = storage.getStore();
if (context === undefined) {
return;
}
context.session = session.session;
context.principal = session.principal;
context.access = identity.access;
}
}

1
apps/README.md Normal file
View File

@@ -0,0 +1 @@
# Apps

24
apps/react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
apps/react/.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

69
apps/react/README.md Normal file
View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/libraries/utils",
"ui": "@/components/ui",
"lib": "@/libraries",
"hooks": "@/hooks"
},
"registries": {}
}

14
apps/react/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

56
apps/react/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@module/account": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/spec": "workspace:*",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "3.35.0",
"@tanstack/react-query": "5.89.0",
"@tanstack/react-router": "1.131.47",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
"@zitadel/react": "1.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fast-equals": "5.2.2",
"lucide-react": "^0.554.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "4.1.13",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "1.4.0",
"zod": "4.1.12"
},
"devDependencies": {
"@eslint/js": "9.35.0",
"@tailwindcss/vite": "4.1.13",
"@tanstack/react-router-devtools": "1.131.47",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "4.7.0",
"eslint": "9.35.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.4.0",
"typescript": "5.9.2",
"typescript-eslint": "8.44.0",
"vite": "7.1.6"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,273 @@
import {
assertServerErrorResponse,
type RelayAdapter,
type RelayInput,
type RelayResponse,
ServerError,
type ServerErrorResponse,
type ServerErrorType,
} from "@platform/relay";
/**
* HttpAdapter provides a unified transport layer for Relay.
*
* It supports sending JSON objects, nested structures, arrays, and file uploads
* via FormData. The adapter automatically detects the payload type and formats
* the request accordingly. Responses are normalized into `RelayResponse`.
*
* @example
* ```ts
* const adapter = new HttpAdapter({ url: "https://api.example.com" });
*
* // Sending JSON data
* const jsonResponse = await adapter.send({
* method: "POST",
* endpoint: "/users",
* body: { name: "Alice", age: 30 },
* });
*
* // Sending files and nested objects
* const formResponse = await adapter.send({
* method: "POST",
* endpoint: "/upload",
* body: {
* user: { name: "Bob", avatar: fileInput.files[0] },
* documents: [fileInput.files[1], fileInput.files[2]],
* },
* });
* ```
*/
export class HttpAdapter implements RelayAdapter {
/**
* Instantiate a new HttpAdapter instance.
*
* @param options - Adapter options.
*/
constructor(readonly options: HttpAdapterOptions) {}
/**
* Override the initial url value set by instantiator.
*/
set url(value: string) {
this.options.url = value;
}
/**
* Retrieve the URL value from options object.
*/
get url() {
return this.options.url;
}
/**
* Return the full URL from given endpoint.
*
* @param endpoint - Endpoint to get url for.
*/
getUrl(endpoint: string): string {
return `${this.url}${endpoint}`;
}
async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
const init: RequestInit = { method, headers };
// ### Before Request
// If any before request hooks has been defined, we run them here passing in the
// request headers for further modification.
await this.#beforeRequest(headers);
// ### Body
if (body !== undefined) {
const type = this.#getRequestFormat(body);
if (type === "form-data") {
headers.delete("content-type");
init.body = this.#getFormData(body);
}
if (type === "json") {
headers.set("content-type", "application/json");
init.body = JSON.stringify(body);
}
}
// ### Response
return this.request(`${endpoint}${query}`, init);
}
/**
* Send a fetch request using the given fetch options and returns
* a relay formatted response.
*
* @param endpoint - Which endpoint to submit request to.
* @param init - Request init details to submit with the request.
*/
async request(endpoint: string, init?: RequestInit): Promise<RelayResponse> {
return this.#toResponse(await fetch(this.getUrl(endpoint), init));
}
/**
* Run before request operations.
*
* @param headers - Headers to pass to hooks.
*/
async #beforeRequest(headers: Headers) {
if (this.options.hooks?.beforeRequest !== undefined) {
for (const hook of this.options.hooks.beforeRequest) {
await hook(headers);
}
}
}
/**
* Determine the parser method required for the request.
*
* @param body - Request body.
*/
#getRequestFormat(body: unknown): "form-data" | "json" {
if (containsFile(body) === true) {
return "form-data";
}
return "json";
}
/**
* Get FormData instance for the given body.
*
* @param body - Request body.
*/
#getFormData(data: Record<string, unknown>, formData = new FormData(), parentKey?: string): FormData {
for (const key in data) {
const value = data[key];
if (value === undefined || value === null) continue;
const formKey = parentKey ? `${parentKey}[${key}]` : key;
if (value instanceof File) {
formData.append(formKey, value, value.name);
} else if (Array.isArray(value)) {
value.forEach((item, index) => {
if (item instanceof File) {
formData.append(`${formKey}[${index}]`, item, item.name);
} else if (typeof item === "object") {
this.#getFormData(item as Record<string, unknown>, formData, `${formKey}[${index}]`);
} else {
formData.append(`${formKey}[${index}]`, String(item));
}
});
} else if (typeof value === "object") {
this.#getFormData(value as Record<string, unknown>, formData, formKey);
} else {
formData.append(formKey, String(value));
}
}
return formData;
}
/**
* Convert a fetch response to a compliant relay response.
*
* @param response - Fetch response to convert.
*/
async #toResponse(response: Response): Promise<RelayResponse> {
const type = response.headers.get("content-type");
// ### Content Type
// Ensure that the server responds with a 'content-type' definition. We should
// always expect the server to respond with a type.
if (type === null) {
return {
result: "error",
error: {
status: response.status,
message: "Missing 'content-type' in header returned from server.",
},
};
}
// ### Empty Response
// If the response comes back with empty response status 204 we simply return a
// empty success.
if (response.status === 204) {
return {
result: "success",
data: null,
};
}
// ### JSON
// If the 'content-type' contains 'json' we treat it as a 'json' compliant response
// and attempt to resolve it as such.
if (type.includes("json") === true) {
const parsed = await response.json();
if ("data" in parsed) {
return {
result: "success",
data: parsed.data,
};
}
if ("error" in parsed) {
return {
result: "error",
error: this.#toError(parsed),
};
}
return {
result: "error",
error: {
status: response.status,
message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.",
},
};
}
return {
result: "error",
error: {
status: response.status,
message: "Unsupported 'content-type' in header returned from server.",
},
};
}
#toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] {
if (assertServerErrorResponse(candidate)) {
return ServerError.fromJSON({ type: "relay", ...candidate.error });
}
if (typeof candidate === "string") {
return {
status,
message: candidate,
};
}
return {
status,
message: "Unsupported 'error' returned from server.",
};
}
}
function containsFile(value: unknown): boolean {
if (value instanceof File) {
return true;
}
if (Array.isArray(value)) {
return value.some(containsFile);
}
if (typeof value === "object" && value !== null) {
return Object.values(value).some(containsFile);
}
return false;
}
export type HttpAdapterOptions = {
url: string;
hooks?: {
beforeRequest?: ((headers: Headers) => Promise<void>)[];
};
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,178 @@
"use client";
import {
IconCamera,
IconChartBar,
IconDashboard,
IconDatabase,
IconFileAi,
IconFileDescription,
IconFileWord,
IconFolder,
IconHelp,
IconInnerShadowTop,
IconListDetails,
IconReport,
IconSearch,
IconSettings,
IconUsers,
} from "@tabler/icons-react";
import type * as React from "react";
import { NavDocuments } from "@/components/nav-documents";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Dashboard",
url: "#",
icon: IconDashboard,
},
{
title: "Lifecycle",
url: "#",
icon: IconListDetails,
},
{
title: "Analytics",
url: "#",
icon: IconChartBar,
},
{
title: "Projects",
url: "#",
icon: IconFolder,
},
{
title: "Team",
url: "#",
icon: IconUsers,
},
],
navClouds: [
{
title: "Capture",
icon: IconCamera,
isActive: true,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Proposal",
icon: IconFileDescription,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Prompts",
icon: IconFileAi,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
],
navSecondary: [
{
title: "Settings",
url: "#",
icon: IconSettings,
},
{
title: "Get Help",
url: "#",
icon: IconHelp,
},
{
title: "Search",
url: "#",
icon: IconSearch,
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: IconDatabase,
},
{
name: "Reports",
url: "#",
icon: IconReport,
},
{
name: "Word Assistant",
url: "#",
icon: IconFileWord,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className="data-[slot=sidebar-menu-button]:!p-1.5">
<a href="#">
<IconInnerShadowTop className="!size-5" />
<span className="text-base font-semibold">Acme Inc.</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { type Icon, IconDots, IconFolder, IconShare3, IconTrash } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavDocuments({
items,
}: {
items: {
name: string;
url: string;
icon: Icon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover className="data-[state=open]:bg-accent rounded-sm">
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { type Icon, IconCirclePlusFilled, IconMail } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: Icon;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button size="icon" className="size-8 group-data-[collapsible=icon]:opacity-0" variant="outline">
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import type { Icon } from "@tabler/icons-react";
import type * as React from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: Icon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,51 @@
import { Controller } from "../libraries/controller.ts";
import { type User as ZitadelUser, zitadel } from "../services/zitadel.ts";
export class NavUserController extends Controller<{
user?: User;
}> {
async onInit() {
return {
user: await this.#getAuthenticatedUser(),
};
}
async #getAuthenticatedUser(): Promise<User | undefined> {
const user = await zitadel.userManager.getUser();
if (user !== null) {
return getUserProfile(user);
}
}
authorize() {
zitadel.authorize();
}
signout() {
zitadel.signout();
}
}
function getUserProfile({ profile }: ZitadelUser): User {
const user: User = { name: "Unknown", email: "unknown@acme.none", avatar: "" };
if (profile.name) {
user.name = profile.name;
} else if (profile.given_name && profile.family_name) {
user.name = `${profile.given_name} ${profile.family_name}`;
} else if (profile.given_name) {
user.name = profile.given_name;
}
if (profile.email) {
user.email = profile.email;
}
if (profile.picture !== undefined) {
user.avatar = profile.picture;
}
return user;
}
type User = {
name: string;
email: string;
avatar: string;
};

View File

@@ -0,0 +1,140 @@
"use client";
import { IconCreditCard, IconDotsVertical, IconLogout, IconNotification, IconUserCircle } from "@tabler/icons-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
import { useController } from "@/libraries/controller.ts";
import { NavUserController } from "./nav-user.controller.ts";
export function NavUser() {
const [{ user }, loading, { authorize, signout }] = useController(NavUserController);
const { isMobile } = useSidebar();
console.log({authorize})
if (loading === true || user === undefined) {
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src="" alt="" />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Guest</span>
<span className="text-muted-foreground truncate text-xs">guest@fixture.none</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="" alt="Guest" />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Guest</span>
<span className="text-muted-foreground truncate text-xs">guest@fixture.none</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => authorize()}>
<IconLogout />
Sign in
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<IconUserCircle />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<IconNotification />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signout()}>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
<h1 className="text-base font-medium">Documents</h1>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
<a
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
rel="noopener noreferrer"
target="_blank"
className="dark:text-foreground"
>
GitHub
</a>
</Button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,67 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,38 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "../../libraries/utils.ts";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,47 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../libraries/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,180 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../libraries/utils.ts";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,21 @@
import type * as React from "react";
import { cn } from "@/libraries/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,38 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "@/libraries/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,26 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@/libraries/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,103 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/libraries/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };

View File

@@ -0,0 +1,643 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/libraries/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,7 @@
import { cn } from "@/libraries/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,46 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/libraries/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

122
apps/react/src/index.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,234 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Minimal Controller for managing component state and lifecycle.
*/
export class Controller<TState extends Record<string, unknown> = {}, TProps extends Record<string, unknown> = {}> {
state: TState = {} as TState;
props: TProps = {} as TProps;
#resolved = false;
#destroyed = false;
#setState: (state: Partial<TState>) => void;
#setLoading: (state: boolean) => void;
constructor(setState: (state: Partial<TState>) => void, setLoading: (state: boolean) => void) {
this.#setState = setState;
this.#setLoading = setLoading;
}
/**
* Factory method to create a new controller instance.
*/
static make<TController extends typeof Controller>(
this: TController,
setState: any,
setLoading: any,
): InstanceType<TController> {
return new this(setState, setLoading) as InstanceType<TController>;
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
/**
* Resolves the controller with given props.
* - First time: Runs onInit() then onResolve()
* - Subsequent times: Runs only onResolve()
*/
async $resolve(props: TProps): Promise<void> {
if (this.#destroyed === true) {
return;
}
this.props = props;
let state: Partial<TState> = {};
try {
if (this.#resolved === false) {
const initState = await this.onInit();
if (initState) {
state = { ...state, ...initState };
}
}
const resolveState = await this.onResolve();
if (resolveState) {
state = { ...state, ...resolveState };
}
} catch (error) {
console.error("Controller resolve error:", error);
throw error;
}
this.#resolved = true;
if (this.#destroyed === false) {
this.setState(state);
}
}
/**
* Destroys the controller and cleans up resources.
*/
async $destroy(): Promise<void> {
this.#destroyed = true;
await this.onDestroy();
}
/*
|--------------------------------------------------------------------------------
| Lifecycle Hooks
|--------------------------------------------------------------------------------
*/
/**
* Called once when the controller is first initialized.
*/
async onInit(): Promise<Partial<TState> | void> {
return {};
}
/**
* Called every time props change (including first mount).
*/
async onResolve(): Promise<Partial<TState> | void> {
return {};
}
/**
* Called when the controller is destroyed.
*/
async onDestroy(): Promise<void> {}
/*
|--------------------------------------------------------------------------------
| State Management
|--------------------------------------------------------------------------------
*/
/**
* Updates the controller state.
*/
setState(state: Partial<TState>): void;
setState<K extends keyof TState>(key: K, value: TState[K]): void;
setState<K extends keyof TState>(...args: [K | Partial<TState>, TState[K]?]): void {
if (this.#destroyed) {
return;
}
const [target, value] = args;
if (typeof target === "string") {
this.state = { ...this.state, [target]: value };
} else {
this.state = { ...this.state, ...(target as Partial<TState>) };
}
this.#setState(this.state);
this.#setLoading(false);
}
/*
|--------------------------------------------------------------------------------
| Actions
|--------------------------------------------------------------------------------
*/
/**
* Returns all public methods as bound actions.
*/
toActions(): ControllerActions<this> {
const actions: any = {};
const prototype = Object.getPrototypeOf(this);
for (const name of Object.getOwnPropertyNames(prototype)) {
if (this.#isAction(name)) {
const method = (this as any)[name];
if (typeof method === "function") {
actions[name] = method.bind(this);
}
}
}
return actions;
}
#isAction(name: string): boolean {
return name !== "constructor" && !name.startsWith("$") && !name.startsWith("_") && !name.startsWith("#");
}
}
/*
|--------------------------------------------------------------------------------
| Hook
|--------------------------------------------------------------------------------
*/
/**
* React hook for using a controller.
*
* @example
* ```tsx
* function LoginView() {
* const [state, actions] = useController(LoginController);
*
* return (
* <button onClick={actions.login}>
* {state.authenticated ? "Logout" : "Login"}
* </button>
* );
* }
* ```
*/
export function useController<TController extends new (...args: any[]) => Controller<any, any>>(
ControllerClass: TController,
props?: InstanceType<TController>["props"],
): [InstanceType<TController>["state"], boolean, ControllerActions<InstanceType<TController>>] {
const [state, setState] = useState<any>({});
const [loading, setLoading] = useState<boolean>(true);
const controllerRef = useRef<InstanceType<TController> | null>(null);
const actionsRef = useRef<ControllerActions<InstanceType<TController>> | null>(null);
const propsRef = useRef(props);
// Resolve only once after creation
useMemo(() => {
const instance = (ControllerClass as any).make(setState, setLoading);
controllerRef.current = instance;
actionsRef.current = instance.toActions();
instance.$resolve(props || {});
return () => {
instance.$destroy();
};
}, [controllerRef]);
// Resolve on props change
useEffect(() => {
if (propsRef.current !== props) {
propsRef.current = props;
controllerRef.current?.$resolve(props || {});
}
}, [props]);
return [state, loading, actionsRef.current!];
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type ControllerActions<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? K extends `$${string}` | `_${string}` | `#${string}` | "constructor"
? never
: T[K]
: never;
};

View File

@@ -0,0 +1,269 @@
import z, { type ZodObject, type ZodRawShape } from "zod";
export class Form<TSchema extends ZodRawShape, TInputs = z.infer<ZodObject<TSchema>>> {
readonly schema: ZodObject<TSchema>;
readonly inputs: Partial<TInputs> = {};
#debounce: FormDebounce<TInputs> = {
validate: {},
};
#defaults: Partial<TInputs>;
#errors: FormErrors<TInputs> = {};
#elements: Record<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = {};
#onChange?: OnChangeCallback<TInputs>;
#onProcessing?: OnProcessingCallback;
#onError?: OnErrorCallback<TInputs>;
#onSubmit?: OnSubmitCallback<TInputs>;
#onResponse?: OnResponseCallback<any, any>;
/*
|--------------------------------------------------------------------------------
| Constructor
|--------------------------------------------------------------------------------
*/
constructor(schema: TSchema, defaults: Partial<TInputs> = {}) {
this.schema = z.object(schema);
this.#defaults = defaults;
this.#bindMethods();
this.#setDefaults();
this.#setSubmit();
}
#bindMethods() {
this.register = this.register.bind(this);
this.set = this.set.bind(this);
this.get = this.get.bind(this);
this.validate = this.validate.bind(this);
this.submit = this.submit.bind(this);
}
#setDefaults() {
for (const key in this.#defaults) {
this.inputs[key] = this.#defaults[key] ?? ("" as any);
}
}
#setSubmit() {
if ((this.constructor as any).submit !== undefined) {
this.onSubmit((this.constructor as any).submit);
}
}
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get isValid(): boolean {
return Object.keys(this.#getFormErrors()).length === 0;
}
get hasError() {
return Object.keys(this.errors).length !== 0;
}
get errors(): FormErrors<TInputs> {
return this.#errors;
}
set errors(value: FormErrors<TInputs>) {
this.#errors = value;
this.#onError?.(value);
}
/**
* Register a input element with the form. This registers form related methods and a
* reference to the element itself that can be utilized by the form.
*
* @param name - Name of the input field.
*/
register<TKey extends keyof TInputs>(name: TKey) {
return {
name,
ref: (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null) => {
if (element !== null) {
this.#elements[name as string] = element;
}
},
defaultValue: this.get(name),
onChange: ({ target: { value } }: any) => {
this.set(name, value);
},
};
}
/*
|--------------------------------------------------------------------------------
| Registrars
|--------------------------------------------------------------------------------
*/
onChange(callback: OnChangeCallback<TInputs>): this {
this.#onChange = callback;
return this;
}
onProcessing(callback: OnProcessingCallback): this {
this.#onProcessing = callback;
return this;
}
onError(callback: OnErrorCallback<TInputs>): this {
this.#onError = callback;
return this;
}
onSubmit(callback: OnSubmitCallback<TInputs>): this {
this.#onSubmit = callback;
return this;
}
onResponse<E, R>(callback: OnResponseCallback<E, R>): this {
this.#onResponse = callback;
return this;
}
/*
|--------------------------------------------------------------------------------
| Data
|--------------------------------------------------------------------------------
*/
/**
* Set the value of an input field.
*
* @param name - Name of the input field.
* @param value - Value to set.
*/
set<TKey extends keyof TInputs>(name: TKey, value: TInputs[TKey]): void {
this.inputs[name] = value;
this.#onChange?.(name, value);
clearTimeout(this.#debounce.validate[name]);
this.#debounce.validate[name] = setTimeout(() => {
this.validate(name);
}, 200);
}
/**
* Get the current input values or a specific input value.
*
* @param name - Name of the input field. _(Optional)_
*/
get(): Partial<TInputs>;
get<TKey extends keyof TInputs>(name: TKey): TInputs[TKey] | undefined;
get<TKey extends keyof TInputs>(name?: TKey): Partial<TInputs> | TInputs[TKey] | undefined {
if (name === undefined) {
return { ...this.inputs };
}
return this.inputs[name];
}
/**
* Reset form back to its default values.
*/
reset() {
for (const key in this.inputs) {
const value = this.#defaults[key] ?? "";
(this.inputs as any)[key] = value;
if (this.#elements[key] !== undefined) {
(this.#elements as any)[key].value = value;
}
}
}
/*
|--------------------------------------------------------------------------------
| Submission
|--------------------------------------------------------------------------------
*/
async submit(event: any) {
event.preventDefault?.();
this.#onProcessing?.(true);
this.validate();
if (this.hasError === false) {
try {
const response = await this.#onSubmit?.(this.schema.parse(this.inputs) as TInputs);
this.#onResponse?.(undefined, response);
} catch (error) {
this.#onResponse?.(error, undefined as any);
}
}
this.#onProcessing?.(false);
this.reset();
}
validate(name?: keyof TInputs) {
if (name !== undefined) {
this.#validateInput(name);
} else {
this.#validateForm();
}
}
#validateForm(): void {
this.errors = this.#getFormErrors();
}
#validateInput(name: keyof TInputs): void {
const errors = this.#getFormErrors();
let hasChanges = false;
if (errors[name] === undefined && this.errors[name] !== undefined) {
delete this.errors[name];
hasChanges = true;
}
if (errors[name] !== undefined && this.errors[name] !== errors[name]) {
this.errors[name] = errors[name];
hasChanges = true;
}
if (hasChanges === true) {
this.#onError?.({ ...this.errors });
}
}
#getFormErrors(): FormErrors<TInputs> {
const result = this.schema.safeParse(this.inputs);
if (result.success === false) {
throw result.error.flatten;
// return result.error.details.reduce<Partial<TInputs>>(
// (error, next) => ({
// ...error,
// [next.path[0]]: next.message,
// }),
// {},
// );
}
return {};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type OnChangeCallback<TInputs, TKey extends keyof TInputs = keyof TInputs> = (name: TKey, value: TInputs[TKey]) => void;
type OnProcessingCallback = (value: boolean) => void;
type OnErrorCallback<TInputs> = (errors: FormErrors<TInputs>) => void;
type OnSubmitCallback<TInputs> = (inputs: TInputs) => Promise<any>;
type OnResponseCallback<Error, Response> = (err: Error, res: Response) => void;
type FormDebounce<TInputs> = {
validate: {
[TKey in keyof TInputs]?: any;
};
};
type FormErrors<TInputs> = {
[TKey in keyof TInputs]?: string;
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

29
apps/react/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import "./index.css";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "./components/theme-provider.tsx";
import { routeTree } from "./routes.tsx";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root");
if (rootElement === null) {
throw new Error("Failed to retrieve root element");
}
createRoot(rootElement).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RouterProvider router={router} />
</ThemeProvider>
</StrictMode>,
);

30
apps/react/src/routes.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { createRootRoute, createRoute } from "@tanstack/react-router";
import { AppView } from "./views/app.view.tsx";
import { CallbackView } from "./views/auth/callback.view.tsx";
import { DashboardView } from "./views/dashboard/dashboard.view.tsx";
const root = createRootRoute();
const callback = createRoute({
getParentRoute: () => root,
path: "/callback",
component: CallbackView,
});
const app = createRoute({
id: "app",
getParentRoute: () => root,
component: AppView,
});
const dashboard = createRoute({
getParentRoute: () => app,
path: "/",
component: DashboardView,
});
root.addChildren([app, callback]);
app.addChildren([dashboard]);
export const routeTree = root;

View File

@@ -0,0 +1,15 @@
import { account } from "@module/account/client";
import { makeClient } from "@platform/relay";
import { HttpAdapter } from "../adapters/http.ts";
export const api = makeClient(
{
adapter: new HttpAdapter({
url: window.location.origin,
}),
},
{
account,
},
);

View File

@@ -0,0 +1,14 @@
import { createZitadelAuth, type ZitadelConfig } from "@zitadel/react";
const config: ZitadelConfig = {
authority: "https://auth.valkyrjs.com",
client_id: "347982179092987909",
redirect_uri: "http://localhost:5173/callback",
post_logout_redirect_uri: "http://localhost:5173",
response_type: "code",
scope: "openid profile email",
};
export const zitadel = createZitadelAuth(config);
export type User = NonNullable<Awaited<ReturnType<typeof zitadel.userManager.getUser>>>;

View File

@@ -0,0 +1,14 @@
import { IndexedDatabase } from "@valkyr/db";
import type { Todo } from "./todo.ts";
import type { TodoItem } from "./todo-item.ts";
import type { User } from "./user.ts";
export const db = new IndexedDatabase<{
todos: Todo;
todoItems: TodoItem;
users: User;
}>({
name: "app:valkyr",
registrars: [{ name: "todos", indexes: [["name", { unique: true }]] }, { name: "todoItems" }, { name: "users" }],
});

View File

@@ -0,0 +1,9 @@
import z from "zod";
export const TodoItemSchema = z.object({
id: z.string(),
task: z.string(),
completedAt: z.string(),
});
export type TodoItem = z.infer<typeof TodoItemSchema>;

View File

@@ -0,0 +1,11 @@
import z from "zod";
import { TodoItemSchema } from "./todo-item.ts";
export const TodoSchema = z.object({
id: z.string(),
name: z.string(),
items: z.array(TodoItemSchema).default([]),
});
export type Todo = z.infer<typeof TodoSchema>;

View File

@@ -0,0 +1,11 @@
import { ContactSchema } from "@spec/schemas/contact.ts";
import { NameSchema } from "@spec/schemas/name.ts";
import z from "zod";
export const UserSchema = z.object({
id: z.string(),
name: NameSchema,
contact: ContactSchema,
});
export type User = z.infer<typeof UserSchema>;

105
apps/react/src/theme.css Normal file
View File

@@ -0,0 +1,105 @@
body {
@apply overscroll-none bg-transparent;
}
:root {
--font-sans: var(--font-inter);
--header-height: calc(var(--spacing) * 12 + 1px);
}
.theme-scaled {
@media (min-width: 1024px) {
--radius: 0.6rem;
--text-lg: 1.05rem;
--text-base: 0.85rem;
--text-sm: 0.8rem;
--spacing: 0.222222rem;
}
[data-slot="card"] {
--spacing: 0.16rem;
}
[data-slot="select-trigger"],
[data-slot="toggle-group-item"] {
--spacing: 0.222222rem;
}
}
.theme-default,
.theme-default-scaled {
--primary: var(--color-neutral-600);
--primary-foreground: var(--color-neutral-50);
@variant dark {
--primary: var(--color-neutral-500);
--primary-foreground: var(--color-neutral-50);
}
}
.theme-blue,
.theme-blue-scaled {
--primary: var(--color-blue-600);
--primary-foreground: var(--color-blue-50);
@variant dark {
--primary: var(--color-blue-500);
--primary-foreground: var(--color-blue-50);
}
}
.theme-green,
.theme-green-scaled {
--primary: var(--color-lime-600);
--primary-foreground: var(--color-lime-50);
@variant dark {
--primary: var(--color-lime-600);
--primary-foreground: var(--color-lime-50);
}
}
.theme-amber,
.theme-amber-scaled {
--primary: var(--color-amber-600);
--primary-foreground: var(--color-amber-50);
@variant dark {
--primary: var(--color-amber-500);
--primary-foreground: var(--color-amber-50);
}
}
.theme-mono,
.theme-mono-scaled {
--font-sans: var(--font-mono);
--primary: var(--color-neutral-600);
--primary-foreground: var(--color-neutral-50);
@variant dark {
--primary: var(--color-neutral-500);
--primary-foreground: var(--color-neutral-50);
}
.rounded-xs,
.rounded-sm,
.rounded-md,
.rounded-lg,
.rounded-xl {
@apply !rounded-none;
border-radius: 0;
}
.shadow-xs,
.shadow-sm,
.shadow-md,
.shadow-lg,
.shadow-xl {
@apply !shadow-none;
}
[data-slot="toggle-group"],
[data-slot="toggle-group-item"] {
@apply !rounded-none !shadow-none;
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "../libraries/controller.ts";
import { zitadel } from "../services/zitadel.ts";
export class AppController extends Controller<{
authenticated: boolean;
}> {
async onInit() {
return {
authenticated: await this.#getAuthenticatedState(),
};
}
async #getAuthenticatedState(): Promise<boolean> {
const user = await zitadel.userManager.getUser();
if (user === null) {
zitadel.authorize();
return false;
}
return true;
}
signout() {
zitadel.signout();
}
}

View File

@@ -0,0 +1,40 @@
import { Outlet } from "@tanstack/react-router";
import { AppSidebar } from "@/components/app-sidebar.tsx";
import { SiteHeader } from "@/components/site-header.tsx";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar.tsx";
import { useController } from "@/libraries/controller.ts";
import { AppController } from "./app.controller.ts";
export function AppView() {
const [{ authenticated }, loading] = useController(AppController);
if (loading === true) {
return <div>Loading ...</div>;
}
if (authenticated === false) {
return <div>Unauthenticated</div>;
}
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<Outlet />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,26 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { zitadel } from "../../services/zitadel.ts";
export function CallbackView() {
const navigate = useNavigate();
useEffect(() => {
async function handleCallback() {
try {
const user = await zitadel.userManager.signinRedirectCallback();
if (user) {
navigate({ to: "/", replace: true });
} else {
navigate({ to: "/", replace: true });
}
} catch (error) {
console.error("Callback error", error);
navigate({ to: "/", replace: true });
}
}
handleCallback();
}, [navigate]);
return null;
}

View File

@@ -0,0 +1,29 @@
import { Controller } from "../../libraries/controller.ts";
import { type User, zitadel } from "../../services/zitadel.ts";
export class LoginController extends Controller<{
user?: User;
}> {
async onInit() {
return {
user: await this.#getAuthenticationState(),
};
}
async #getAuthenticationState(): Promise<User | undefined> {
return zitadel.userManager.getUser().then((user) => {
if (user === null) {
return undefined;
}
return user;
});
}
login() {
zitadel.authorize();
}
logout() {
zitadel.signout();
}
}

View File

@@ -0,0 +1,14 @@
import { useController } from "../../libraries/controller.ts";
import { LoginController } from "./login.controller.ts";
export function LoginView() {
const [{ user }, { login, logout }] = useController(LoginController);
return (
<div>
<button type="button" onClick={() => (user === undefined ? login() : logout())}>
{user === undefined ? "Login" : "Logout"}
</button>
{user !== undefined ? <pre>{JSON.stringify(user, null, 2)}</pre> : null}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function DashboardView() {
return <div>Dashboard</div>;
}

1
apps/react/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,9 @@
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
},
},
};

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

10
apps/react/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

20
apps/react/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import path from "node:path"
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api/v1": {
target: "http://localhost:8370",
},
},
},
});

45
biome.json Normal file
View File

@@ -0,0 +1,45 @@
{
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 120,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConfusingVoidType": "off",
"noExplicitAny": "off"
},
"complexity": {
"noBannedTypes": "off"
}
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
[":BUN:", ":NODE:"],
":BLANK_LINE:",
":PACKAGE:",
":BLANK_LINE:",
[":ALIAS:"],
":BLANK_LINE:",
":PATH:"
]
}
}
}
}
}
}

13
cerbos.yaml Normal file
View File

@@ -0,0 +1,13 @@
server:
adminAPI:
enabled: true
adminCredentials:
username: cerbos
passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo=
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: "sqlite3"
sqlite3:
dsn: "file:/tmp/cerbos.sqlite?mode=rwc&cache=shared&_fk=true"

View File

@@ -1,23 +1,43 @@
{ {
"name": "@valkyr/relay", "unstable": ["fmt-component"],
"version": "0.1.0", "nodeModulesDir": "auto",
"exports": { "workspace": [
".": "./mod.ts" "api",
}, "apps/react",
"publish": { "modules/account",
"exclude": [ "modules/tenant",
".github", "platform/cerbos",
".vscode", "platform/config",
".gitignore", "platform/database",
"tests" "platform/logger",
] "platform/relay",
}, "platform/routes",
"platform/server",
"platform/socket",
"platform/spec",
"platform/storage",
"platform/vault"
],
"tasks": { "tasks": {
"check": "deno check ./mod.ts", "api": {
"lint": "npx eslint -c eslint.config.mjs .", "command": "cd ./api && deno run start",
"test": "deno test --allow-all", "description": "Start api server instance."
"test:publish": "deno publish --dry-run", },
"ncu": "npx ncu -u -p npm" "react": {
}, "command": "cd ./apps/react && deno run dev",
"nodeModulesDir": "auto" "description": "Start react application instance."
},
"check": {
"command": "deno run -A npm:@biomejs/biome check --write ./api ./apps/react/src ./modules ./platform",
"description": "Format, lint, and organize imports of the entire project."
},
"test": {
"command": "deno test --allow-all",
"description": "Runs all defined tests across the entire project."
},
"ncu": {
"command": "npx ncu -u -p npm",
"description": "Updates all the dependencies in package.json to their latest versions."
}
}
} }

2506
deno.lock generated

File diff suppressed because it is too large Load Diff

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
networks:
server:
name: server
volumes:
mongo:
driver: local
services:
# MongoDB
# --------------------------------------------------------------------------------
# Used by event store and read store for managing and reading application data.
mongo:
restart: unless-stopped
image: mongo:8
container_name: boilerplate_mongo
ports:
- 6017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password
volumes:
- mongo:/data/db
networks:
- server
# Cerbos
# --------------------------------------------------------------------------------
# Policy engine for application access control.
cerbos:
restart: unless-stopped
image: ghcr.io/cerbos/cerbos:latest
container_name: boilerplate_cerbos
command: ["server", "--config=/config.yaml"]
ports:
- 6592:3592
- 6593:3593
- 6594:3594
volumes:
- ./cerbos.yaml:/config.yaml
networks:
- server

View File

@@ -1,30 +0,0 @@
import simpleImportSort from "eslint-plugin-simple-import-sort";
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommended,
{
plugins: {
"simple-import-sort": simpleImportSort,
},
rules: {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
},
},
{
files: ["**/*.ts"],
rules: {
"@typescript-eslint/ban-ts-comment": ["error", {
"ts-expect-error": "allow-with-description",
minimumDescriptionLength: 10,
}],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
}],
},
},
];

View File

@@ -1,63 +0,0 @@
import z, { ZodObject, ZodRawShape } from "zod";
export class Action<TActionState extends ActionState = ActionState> {
constructor(readonly state: TActionState) {}
/**
* Input object required by the action to fulfill its function.
*
* @param input - Schema defining the input requirements of the action.
*/
input<TInput extends ZodRawShape>(input: TInput): Action<Omit<TActionState, "input"> & { input: ZodObject<TInput> }> {
return new Action({ ...this.state, input: z.object(input) as any });
}
/**
* Output object defining the result shape of the action.
*
* @param output - Schema defining the result shape.
*/
output<TOutput extends ZodRawShape>(output: TOutput): Action<Omit<TActionState, "output"> & { output: ZodObject<TOutput> }> {
return new Action({ ...this.state, output: z.object(output) as any });
}
/**
* Add handler method to the action.
*
* @param handle - Handler method.
*/
handle<THandleFn extends ActionHandlerFn<this["state"]["input"], this["state"]["output"]>>(
handle: THandleFn,
): Action<Omit<TActionState, "handle"> & { handle: THandleFn }> {
return new Action({ ...this.state, handle });
}
}
/*
|--------------------------------------------------------------------------------
| Factory
|--------------------------------------------------------------------------------
*/
export const action = {
make(name: string) {
return new Action({ name });
},
};
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type ActionState = {
name: string;
input?: ZodObject;
output?: ZodObject;
handle?: ActionHandlerFn;
};
type ActionHandlerFn<TInput = any, TOutput = any> = TInput extends ZodObject
? (input: z.infer<TInput>) => TOutput extends ZodObject ? Promise<z.infer<TOutput>> : Promise<void>
: () => TOutput extends ZodObject ? Promise<z.infer<TOutput>> : Promise<void>;

View File

@@ -1,227 +0,0 @@
export abstract class RelayError<D = unknown> extends Error {
constructor(
message: string,
readonly status: number,
readonly data?: D,
) {
super(message);
}
toJSON() {
return {
status: this.status,
message: this.message,
data: this.data,
};
}
}
export class BadRequestError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new BadRequestError.
*
* The **HTTP 400 Bad Request** response status code indicates that the server
* cannot or will not process the request due to something that is perceived to
* be a client error.
*
* @param data - Optional data to send with the error.
*/
constructor(message = "Bad Request", data?: D) {
super(message, 400, data);
}
}
export class UnauthorizedError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new UnauthorizedError.
*
* The **HTTP 401 Unauthorized** response status code indicates that the client
* request has not been completed because it lacks valid authentication
* credentials for the requested resource.
*
* This status code is sent with an HTTP WWW-Authenticate response header that
* contains information on how the client can request for the resource again after
* prompting the user for authentication credentials.
*
* This status code is similar to the **403 Forbidden** status code, except that
* in situations resulting in this status code, user authentication can allow
* access to the resource.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401
*
* @param message - Optional message to send with the error. Default: "Unauthorized".
* @param data - Optional data to send with the error.
*/
constructor(message = "Unauthorized", data?: D) {
super(message, 401, data);
}
}
export class ForbiddenError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new ForbiddenError.
*
* The **HTTP 403 Forbidden** response status code indicates that the server
* understands the request but refuses to authorize it.
*
* This status is similar to **401**, but for the **403 Forbidden** status code
* re-authenticating makes no difference. The access is permanently forbidden and
* tied to the application logic, such as insufficient rights to a resource.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403
*
* @param message - Optional message to send with the error. Default: "Forbidden".
* @param data - Optional data to send with the error.
*/
constructor(message = "Forbidden", data?: D) {
super(message, 403, data);
}
}
export class NotFoundError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new NotFoundError.
*
* The **HTTP 404 Not Found** response status code indicates that the server
* cannot find the requested resource. Links that lead to a 404 page are often
* called broken or dead links and can be subject to link rot.
*
* A 404 status code only indicates that the resource is missing: not whether the
* absence is temporary or permanent. If a resource is permanently removed,
* use the **410 _(Gone)_** status instead.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
*
* @param message - Optional message to send with the error. Default: "Not Found".
* @param data - Optional data to send with the error.
*/
constructor(message = "Not Found", data?: D) {
super(message, 404, data);
}
}
export class NotAcceptableError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new NotAcceptableError.
*
* The **HTTP 406 Not Acceptable** client error response code indicates that the
* server cannot produce a response matching the list of acceptable values
* defined in the request, and that the server is unwilling to supply a default
* representation.
*
* @param message - Optional message to send with the error. Default: "Not Acceptable".
* @param data - Optional data to send with the error.
*/
constructor(message = "Not Acceptable", data?: D) {
super(message, 406, data);
}
}
export class ConflictError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new ConflictError.
*
* The **HTTP 409 Conflict** response status code indicates a request conflict
* with the current state of the target resource.
*
* Conflicts are most likely to occur in response to a PUT request. For example,
* you may get a 409 response when uploading a file that is older than the
* existing one on the server, resulting in a version control conflict.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409
*
* @param message - Optional message to send with the error. Default: "Conflict".
* @param data - Optional data to send with the error.
*/
constructor(message = "Conflict", data?: D) {
super(message, 409, data);
}
}
export class GoneError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new GoneError.
*
* The **HTTP 410 Gone** indicates that the target resource is no longer
* available at the origin server and that this condition is likely to be
* permanent. A 410 response is cacheable by default.
*
* Clients should not repeat requests for resources that return a 410 response,
* and website owners should remove or replace links that return this code. If
* server owners don't know whether this condition is temporary or permanent,
* a 404 status code should be used instead.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410
*
* @param message - Optional message to send with the error. Default: "Gone".
* @param data - Optional data to send with the error.
*/
constructor(message = "Gone", data?: D) {
super(message, 410, data);
}
}
export class UnprocessableContentError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new UnprocessableContentError.
*
* The **HTTP 422 Unprocessable Content** client error response status code
* indicates that the server understood the content type of the request entity,
* and the syntax of the request entity was correct, but it was unable to
* process the contained instructions.
*
* Clients that receive a 422 response should expect that repeating the request
* without modification will fail with the same error.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
*
* @param message - Optional message to send with the error. Default: "Unprocessable Content".
* @param data - Optional data to send with the error.
*/
constructor(message = "Unprocessable Content", data?: D) {
super(message, 422, data);
}
}
export class InternalServerError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new InternalServerError.
*
* The **HTTP 500 Internal Server Error** server error response code indicates that
* the server encountered an unexpected condition that prevented it from fulfilling
* the request.
*
* This error response is a generic "catch-all" response. Usually, this indicates
* the server cannot find a better 5xx error code to response. Sometimes, server
* administrators log error responses like the 500 status code with more details
* about the request to prevent the error from happening again in the future.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500
*
* @param message - Optional message to send with the error. Default: "Internal Server Error".
* @param data - Optional data to send with the error.
*/
constructor(message = "Internal Server Error", data?: D) {
super(message, 500, data);
}
}
export class ServiceUnavailableError<D = unknown> extends RelayError<D> {
/**
* Instantiate a new ServiceUnavailableError.
*
* The **HTTP 503 Service Unavailable** server error response status code indicates
* that the server is not ready to handle the request.
*
* This response should be used for temporary conditions and the Retry-After HTTP header
* should contain the estimated time for the recovery of the service, if possible.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503
*
* @param message - Optional message to send with the error. Default: "Service Unavailable".
* @param data - Optional data to send with the error.
*/
constructor(message = "Service Unavailable", data?: D) {
super(message, 503, data);
}
}

View File

@@ -1,449 +0,0 @@
import z, { ZodType } from "zod";
import { BadRequestError, NotFoundError, RelayError } from "./errors.ts";
import { Route, RouteMethod } from "./route.ts";
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export class Relay<TRoutes extends Route[]> {
/**
* Route maps funneling registered routes to the specific methods supported by
* the relay instance.
*/
readonly routes: Routes = {
POST: [],
GET: [],
PUT: [],
PATCH: [],
DELETE: [],
};
/**
* List of paths in the '${method} ${path}' format allowing us to quickly throw
* errors if a duplicate route path is being added.
*/
readonly #paths = new Set<string>();
/**
* Route index in the '${method} ${path}' format allowing for quick access to
* a specific route.
*/
readonly #index = new Map<string, Route>();
/**
* Instantiate a new Relay instance.
*
* @param config - Relay configuration to apply to the instance.
* @param routes - Routes to register with the instance.
*/
constructor(
readonly config: RelayConfig,
routes: TRoutes,
) {
const methods: (keyof typeof this.routes)[] = [];
for (const route of routes) {
this.#validateRoutePath(route);
this.routes[route.method].push(route);
methods.push(route.method);
this.#index.set(`${route.method} ${route.path}`, route);
}
for (const method of methods) {
this.routes[method].sort(byStaticPriority);
}
}
/*
|--------------------------------------------------------------------------------
| Agnostic
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a route for the given method/path combination which can be further extended
* for serving incoming third party requests.
*
* @param method - Method the route is registered for.
* @param path - Path the route is registered under.
*
* @examples
*
* ```ts
* const relay = new Relay([
* route
* .post("/users")
* .body(
* z.object({
* name: z.object({ family: z.string(), given: z.string() }),
* email: z.string().check(z.email()),
* })
* )
* ]);
*
* relay
* .route("POST", "/users")
* .actions([hasSessionUser, hasAccess("users", "create")])
* .handle(async ({ name, email, sessionUserId }) => {
* // await db.users.insert({ name, email, createdBy: sessionUserId });
* })
* ```
*/
route<
TMethod extends RouteMethod,
TPath extends Extract<TRoutes[number], { state: { method: TMethod } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: TMethod; path: TPath } }>,
>(method: TMethod, path: TPath): TRoute {
const route = this.#index.get(`${method} ${path}`);
if (route === undefined) {
throw new Error(`Relay > Route not found at '${method} ${path}' index`);
}
return route as TRoute;
}
/*
|--------------------------------------------------------------------------------
| Client
|--------------------------------------------------------------------------------
*/
/**
* Send a "POST" request through the relay `fetch` adapter.
*
* @param path - Path to send request to.
* @param args - List of request arguments.
*/
async post<
TPath extends Extract<TRoutes[number], { state: { method: "POST" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "POST"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("POST", path, args) as RelayResponse<TRoute>;
}
/**
* Send a "GET" request through the relay `fetch` adapter.
*
* @param path - Path to send request to.
* @param args - List of request arguments.
*/
async get<
TPath extends Extract<TRoutes[number], { state: { method: "GET" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "GET"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("GET", path, args) as RelayResponse<TRoute>;
}
/**
* Send a "PUT" request through the relay `fetch` adapter.
*
* @param path - Path to send request to.
* @param args - List of request arguments.
*/
async put<
TPath extends Extract<TRoutes[number], { state: { method: "PUT" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "PUT"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("PUT", path, args) as RelayResponse<TRoute>;
}
/**
* Send a "PATCH" request through the relay `fetch` adapter.
*
* @param path - Path to send request to.
* @param args - List of request arguments.
*/
async patch<
TPath extends Extract<TRoutes[number], { state: { method: "PATCH" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "PATCH"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("PATCH", path, args) as RelayResponse<TRoute>;
}
/**
* Send a "DELETE" request through the relay `fetch` adapter.
*
* @param path - Path to send request to.
* @param args - List of request arguments.
*/
async delete<
TPath extends Extract<TRoutes[number], { state: { method: "DELETE" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "DELETE"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("DELETE", path, args) as RelayResponse<TRoute>;
}
/*
|--------------------------------------------------------------------------------
| Server
|--------------------------------------------------------------------------------
*/
/**
* Handle a incoming fetch request.
*
* @param request - Fetch request to pass to a route handler.
*/
async handle(request: Request) {
const url = new URL(request.url);
const matched = this.#resolve(request.method, request.url);
if (matched === undefined) {
return toResponse(
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
method: request.method,
url: request.url,
}),
);
}
const { route, params } = matched;
// ### Context
// Context is passed to every route handler and provides a suite of functionality
// and request data.
const context = {
...params,
...toSearch(url.searchParams),
};
// ### Params
// If the route has params we want to coerce the values to the expected types.
if (route.state.params !== undefined) {
const result = await route.state.params.safeParseAsync(context.params);
if (result.success === false) {
return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error)));
}
context.params = result.data;
}
// ### Query
// If the route has a query schema we need to validate and parse the query.
if (route.state.search !== undefined) {
const result = await route.state.search.safeParseAsync(context.query ?? {});
if (result.success === false) {
return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error)));
}
context.query = result.data;
}
// ### Body
// If the route has a body schema we need to validate and parse the body.
const body: Record<string, unknown> = {};
if (route.state.body !== undefined) {
const result = await route.state.body.safeParseAsync(body);
if (result.success === false) {
return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
}
context.body = result.data;
}
// ### Actions
// Run through all assigned actions for the route.
if (route.state.actions !== undefined) {
for (const action of route.state.actions) {
const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} };
if (result.success === false) {
return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)));
}
const output = (await action.state.handle?.(result.data)) ?? {};
for (const key in output) {
context[key] = output[key];
}
}
}
// ### Handler
// Execute the route handler and apply the result.
return toResponse(await route.state.handle?.(context).catch((error) => error));
}
/**
* Attempt to resolve a route based on the given method and pathname.
*
* @param method - HTTP method.
* @param url - HTTP request url.
*/
#resolve(method: string, url: string): ResolvedRoute | undefined {
this.#assertMethod(method);
for (const route of this.routes[method]) {
if (route.match(url) === true) {
return { route, params: route.getParsedParams(url) };
}
}
}
#validateRoutePath(route: Route): void {
const path = `${route.method} ${route.path}`;
if (this.#paths.has(path)) {
throw new Error(`Router > Path ${path} already exists`);
}
this.#paths.add(path);
}
async #send(method: RouteMethod, url: string, args: any[]) {
const route = this.route(method, url);
// ### Input
const input: RequestInput = { method, url, search: "" };
let index = 0; // argument incrementor
if (route.state.params !== undefined) {
const params = args[index++] as { [key: string]: string };
for (const key in params) {
input.url = input.url.replace(`:${key}`, params[key]);
}
}
if (route.state.search !== undefined) {
const search = args[index++] as { [key: string]: string };
const pieces: string[] = [];
for (const key in search) {
pieces.push(`${key}=${search[key]}`);
}
if (pieces.length > 0) {
input.search = `?${pieces.join("&")}`;
}
}
if (route.state.body !== undefined) {
input.body = JSON.stringify(args[index++]);
}
// ### Fetch
const data = await this.config.adapter.fetch(input);
if (route.state.output !== undefined) {
return route.state.output.parse(data);
}
return data;
}
#assertMethod(method: string): asserts method is RouteMethod {
if (!SUPPORTED_MEHODS.includes(method)) {
throw new Error(`Router > Unsupported method '${method}'`);
}
}
}
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
/**
* Sorting method for routes to ensure that static properties takes precedence
* for when a route is matched against incoming requests.
*
* @param a - Route A
* @param b - Route B
*/
function byStaticPriority(a: Route, b: Route) {
const aSegments = a.path.split("/");
const bSegments = b.path.split("/");
const maxLength = Math.max(aSegments.length, bSegments.length);
for (let i = 0; i < maxLength; i++) {
const aSegment = aSegments[i] || "";
const bSegment = bSegments[i] || "";
const isADynamic = aSegment.startsWith(":");
const isBDynamic = bSegment.startsWith(":");
if (isADynamic !== isBDynamic) {
return isADynamic ? 1 : -1;
}
if (isADynamic === false && aSegment !== bSegment) {
return aSegment.localeCompare(bSegment);
}
}
return a.path.localeCompare(b.path);
}
/**
* Resolve and return query object from the provided search parameters, or undefined
* if the search parameters does not have any entries.
*
* @param searchParams - Search params to create a query object from.
*/
function toSearch(searchParams: URLSearchParams): object | undefined {
if (searchParams.size === 0) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
result[key] = value;
}
return result;
}
/**
* Takes a server side request result and returns a fetch Response.
*
* @param result - Result to send back as a Response.
*/
function toResponse(result: object | RelayError | Response | void): Response {
if (result instanceof Response) {
return result;
}
if (result instanceof RelayError) {
return new Response(result.message, {
status: result.status,
});
}
if (result === undefined) {
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify(result), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Routes = {
POST: Route[];
GET: Route[];
PUT: Route[];
PATCH: Route[];
DELETE: Route[];
};
type ResolvedRoute = {
route: Route;
params: any;
};
type RelayResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
type RelayConfig = {
adapter: RelayAdapter;
};
export type RelayAdapter = {
fetch(input: RequestInput): Promise<unknown>;
};
export type RequestInput = {
method: RouteMethod;
url: string;
search: string;
body?: string;
};

View File

@@ -1,332 +0,0 @@
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
import { Action } from "./action.ts";
export class Route<TRouteState extends RouteState = RouteState> {
#pattern?: URLPattern;
declare readonly args: RouteArgs<TRouteState>;
declare readonly context: RouteContext<TRouteState>;
constructor(readonly state: TRouteState) {}
/**
* HTTP Method
*/
get method(): RouteMethod {
return this.state.method;
}
/**
* URL pattern of the route.
*/
get pattern(): URLPattern {
if (this.#pattern === undefined) {
this.#pattern = new URLPattern({ pathname: this.path });
}
return this.#pattern;
}
/**
* URL path
*/
get path(): string {
return this.state.path;
}
/**
* Check if the provided URL matches the route pattern.
*
* @param url - HTTP request.url
*/
match(url: string): boolean {
return this.pattern.test(url);
}
/**
* Extract parameters from the provided URL based on the route pattern.
*
* @param url - HTTP request.url
*/
getParsedParams(url: string): TRouteState["params"] extends ZodObject ? z.infer<TRouteState["params"]> : object {
const params = this.pattern.exec(url)?.pathname.groups;
if (params === undefined) {
return {};
}
return this.state.params?.parse(params) ?? params;
}
/**
* Params allows for custom casting of URL parameters. If a parameter does not
* have a corresponding zod schema the default param type is "string".
*
* @param params - URL params.
*
* @examples
*
* ```ts
* route
* .post("/foo/:bar")
* .params({
* bar: z.number({ coerce: true })
* })
* .handle(async ({ params: { bar } }) => {
* console.log(typeof bar); // => number
* });
* ```
*/
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TRouteState, "params"> & { params: ZodObject<TParams> }> {
return new Route({ ...this.state, params }) as any;
}
/**
* Search allows for custom casting of URL search parameters. If a parameter does
* not have a corresponding zod schema the default param type is "string".
*
* @param search - URL search arguments.
*
* @examples
*
* ```ts
* route
* .post("/foo")
* .search({
* bar: z.number({ coerce: true })
* })
* .handle(async ({ search: { bar } }) => {
* console.log(typeof bar); // => number
* });
* ```
*/
search<TSearch extends ZodRawShape>(search: TSearch): Route<Omit<TRouteState, "search"> & { search: ZodObject<TSearch> }> {
return new Route({ ...this.state, search }) as any;
}
/**
* Shape of the body this route expects to receive. This is used by all
* mutator routes and has no effect when defined on "GET" methods.
*
* @param body - Body the route expects.
*
* @examples
*
* ```ts
* route
* .post("/foo")
* .body(
* z.object({
* bar: z.number()
* })
* )
* .handle(async ({ bar }) => {
* console.log(typeof bar); // => number
* });
* ```
*/
body<TBody extends ZodObject>(body: TBody): Route<Omit<TRouteState, "body"> & { body: TBody }> {
return new Route({ ...this.state, body });
}
/**
* List of route level middleware action to execute before running the
* route handler.
*
* @param actions - Actions to execute on this route.
*
* @examples
*
* ```ts
* const hasFooBar = action
* .make("hasFooBar")
* .response(z.object({ foobar: z.number() }))
* .handle(async () => {
* return {
* foobar: 1,
* };
* });
*
* route
* .post("/foo")
* .actions([hasFooBar])
* .handle(async ({ foobar }) => {
* console.log(typeof foobar); // => number
* });
* ```
*/
actions<TAction extends Action>(actions: TAction[]): Route<Omit<TRouteState, "actions"> & { actions: TAction[] }> {
return new Route({ ...this.state, actions });
}
/**
* Shape of the response this route produces. This is used by the transform
* tools to ensure the client receives parsed data.
*
* @param response - Response shape of the route.
*
* @examples
*
* ```ts
* route
* .post("/foo")
* .response(
* z.object({
* bar: z.number()
* })
* )
* .handle(async () => {
* return {
* bar: 1
* }
* });
* ```
*/
response<TResponse extends ZodType>(output: TResponse): Route<Omit<TRouteState, "output"> & { output: TResponse }> {
return new Route({ ...this.state, output });
}
/**
* Server handler callback method.
*
* @param handle - Handle function to trigger when the route is executed.
*/
handle<THandleFn extends HandleFn<this["context"], this["state"]["output"]>>(handle: THandleFn): Route<Omit<TRouteState, "handle"> & { handle: THandleFn }> {
return new Route({ ...this.state, handle });
}
}
/*
|--------------------------------------------------------------------------------
| Factories
|--------------------------------------------------------------------------------
*/
/**
* Route factories allowing for easy generation of relay compliant routes.
*/
export const route = {
/**
* Create a new "POST" route for the given path.
*
* @param path - Path to generate route for.
*
* @examples
*
* ```ts
* route
* .post("/foo")
* .body(
* z.object({ bar: z.string() })
* );
* ```
*/
post<TPath extends string>(path: TPath) {
return new Route({ method: "POST", path });
},
/**
* Create a new "GET" route for the given path.
*
* @param path - Path to generate route for.
*
* @examples
*
* ```ts
* route.get("/foo");
* ```
*/
get<TPath extends string>(path: TPath) {
return new Route({ method: "GET", path });
},
/**
* Create a new "PUT" route for the given path.
*
* @param path - Path to generate route for.
*
* @examples
*
* ```ts
* route
* .put("/foo")
* .body(
* z.object({ bar: z.string() })
* );
* ```
*/
put<TPath extends string>(path: TPath) {
return new Route({ method: "PUT", path });
},
/**
* Create a new "PATCH" route for the given path.
*
* @param path - Path to generate route for.
*
* @examples
*
* ```ts
* route
* .patch("/foo")
* .body(
* z.object({ bar: z.string() })
* );
* ```
*/
patch<TPath extends string>(path: TPath) {
return new Route({ method: "PATCH", path });
},
/**
* Create a new "DELETE" route for the given path.
*
* @param path - Path to generate route for.
*
* @examples
*
* ```ts
* route.delete("/foo");
* ```
*/
delete<TPath extends string>(path: TPath) {
return new Route({ method: "DELETE", path });
},
};
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type RouteState = {
method: RouteMethod;
path: string;
params?: ZodObject;
search?: ZodObject;
body?: ZodObject;
actions?: Array<Action>;
output?: ZodType;
handle?: HandleFn;
};
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
export type HandleFn<TContext = any, TResponse = any> = (context: TContext) => TResponse extends ZodType ? Promise<z.infer<TResponse>> : Promise<void>;
type RouteContext<TRouteState extends RouteState = RouteState> = (TRouteState["params"] extends ZodObject ? z.infer<TRouteState["params"]> : object) &
(TRouteState["search"] extends ZodObject ? z.infer<TRouteState["search"]> : object) &
(TRouteState["body"] extends ZodObject ? z.infer<TRouteState["body"]> : object) &
(TRouteState["actions"] extends Array<Action> ? UnionToIntersection<MergeAction<TRouteState["actions"]>> : object);
type RouteArgs<TRouteState extends RouteState = RouteState> = [
...TupleIfZod<TRouteState["params"]>,
...TupleIfZod<TRouteState["search"]>,
...TupleIfZod<TRouteState["body"]>,
];
type TupleIfZod<TState> = TState extends ZodObject ? [z.infer<TState>] : [];
type MergeAction<TActions extends Array<Action>> =
TActions[number] extends Action<infer TActionState> ? (TActionState["output"] extends ZodObject ? z.infer<TActionState["output"]> : object) : object;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;

4
mod.ts
View File

@@ -1,4 +0,0 @@
export * from "./libraries/action.ts";
export * from "./libraries/errors.ts";
export * from "./libraries/relay.ts";
export * from "./libraries/route.ts";

View File

@@ -0,0 +1,4 @@
export const account = {
create: (await import("./routes/create/spec.ts")).default,
get: (await import("./routes/get/spec.ts")).default,
};

View File

@@ -0,0 +1,14 @@
{
"name": "@module/account",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./server": "./server.ts",
"./client": "./client.ts"
},
"dependencies": {
"@platform/relay": "workspace:*",
"zod": "4.1.12"
}
}

View File

@@ -0,0 +1,5 @@
import route from "./spec.ts";
export default route.handle(async ({ body }) => {
console.log(body);
});

View File

@@ -0,0 +1,13 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.post("/api/v1/account").body(
z.strictObject({
tenantId: z.uuid().describe("Tenant identifier the account belongs to"),
userId: z.uuid().describe("User identifier the account belongs to"),
account: z.strictObject({
type: z.string().describe("Type of account being created"),
number: z.number().describe("Unique account identifier to create for the account"),
}),
}),
);

View File

@@ -0,0 +1,5 @@
import route from "./spec.ts";
export default route.handle(async ({ params }) => {
console.log(params);
});

View File

@@ -0,0 +1,6 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.get("/api/v1/account/:number").params({
number: z.number().describe("Account number to retrieve"),
});

View File

View File

@@ -0,0 +1,6 @@
{
"name": "@module/tenant",
"version": "0.0.0",
"private": true,
"type": "module"
}

View File

@@ -1,13 +1,7 @@
{ {
"dependencies": {
"zod": "next"
},
"devDependencies": { "devDependencies": {
"@std/assert": "npm:@jsr/std__assert@1.0.12", "@std/assert": "npm:@jsr/std__assert@1.0.14",
"@std/testing": "npm:@jsr/std__testing@1.0.11", "@std/testing": "npm:@jsr/std__testing@1.0.15",
"eslint": "9.24.0", "@biomejs/biome": "2.2.4"
"eslint-plugin-simple-import-sort": "12.1.1",
"prettier": "3.5.3",
"typescript-eslint": "8.30.1"
} }
} }

View File

@@ -0,0 +1,10 @@
{
"name": "@platform/cerbos",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@cerbos/core": "0.25.1",
"@cerbos/http": "0.23.3"
}
}

10
platform/config/dotenv.ts Normal file
View File

@@ -0,0 +1,10 @@
import { load } from "@std/dotenv";
const env = await load();
/**
* TODO ...
*/
export function getDotEnvVariable(key: string): string {
return env[key] ?? Deno.env.get(key);
}

View File

@@ -0,0 +1,51 @@
import { load } from "@std/dotenv";
import type { ZodType, z } from "zod";
import { InvalidEnvironmentKeyError } from "./errors.ts";
import { getServiceEnvironment, type ServiceEnvironment } from "./service.ts";
const env = await load();
/**
* TODO ...
*/
export function getEnvironmentVariable<TType extends ZodType>({
key,
type,
envFallback,
fallback,
}: {
key: string;
type: TType;
envFallback?: EnvironmentFallback;
fallback?: string;
}): z.infer<TType> {
const serviceEnv = getServiceEnvironment();
const providedValue = env[key] ?? Deno.env.get(key);
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
const toBeUsed = providedValue ?? fallbackValue;
try {
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
return type.parse(JSON.parse(toBeUsed));
}
return type.parse(toBeUsed);
} catch (error) {
throw new InvalidEnvironmentKeyError(key, {
cause: error,
});
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type EnvironmentFallback = Partial<Record<ServiceEnvironment, string>> & {
testing?: string;
local?: string;
stg?: string;
demo?: string;
prod?: string;
};

Some files were not shown because too many files have changed in this diff Show More