Initial commit frontend + backend with client package

This commit is contained in:
Oliver Kaup 2025-12-18 20:00:47 +00:00
parent f4d5a66947
commit bf989e6b82
No known key found for this signature in database
120 changed files with 10009 additions and 3610 deletions

19
.dockerignore Normal file
View File

@ -0,0 +1,19 @@
node_modules
npm-debug.log
.env
.env.*
dist
.turbo
.next
coverage
*.log
.git
.gitignore
README.md
.vscode
.idea
**/.DS_Store
**/node_modules
**/.turbo
**/dist
**/.next

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# Root Environment Configuration
# This file contains shared configuration for docker-compose and applications
# ==========================================
# Postgres Database Configuration
# ==========================================
POSTGRES_DB=backend
POSTGRES_USER=admin
POSTGRES_PASSWORD=testing123
# ==========================================
# PgAdmin Configuration
# ==========================================
PGADMIN_DEFAULT_EMAIL=pgadmin4@pgadmin.org
PGADMIN_DEFAULT_PASSWORD=admin
PGADMIN_PORT=5050
# ==========================================
# Usage:
# ==========================================
# 1. Docker Compose: Uses these values automatically
# 2. Backend App: Reference in apps/backend/.env.dev or .env.local
# Example: DB_USER=admin (matching POSTGRES_USER)

10
.vscode/settings.json vendored
View File

@ -1,7 +1,7 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

242
README.md
View File

@ -1,126 +1,184 @@
# Turborepo starter
# Ollis Turborepo Test
This Turborepo starter is maintained by the Turborepo core team.
A full-stack TypeScript monorepo demonstrating modern development practices with automatic API client generation and type-safe end-to-end data flow.
## Using this example
## Architecture Overview
Run the following command:
This monorepo showcases a **type-safe, auto-generated API contract** between backend and frontend:
```sh
npx create-turbo@latest
```
Backend (Fastify) → OpenAPI Spec → Generated API Client → Frontend (SolidJS)
```
### Key Design Decisions
1. **OpenAPI-First API Design**: The backend uses Fastify with `@fastify/swagger` to automatically generate OpenAPI specifications from TypeScript schemas.
2. **Automatic Client Generation**: The `api-client-backend` package uses `@hey-api/openapi-ts` to generate a fully typed API client from the OpenAPI spec, ensuring type safety across the stack.
3. **Turborepo Watch Mode**: Development workflow uses Turborepo's watch feature to orchestrate the entire pipeline automatically - when you change an API schema, the OpenAPI spec is regenerated, the client is regenerated, and the frontend picks up changes via HMR.
4. **PNPM Workspace**: All packages are managed in a monorepo with workspace protocol for instant local dependency updates.
5. **Environment-Based Configuration**: Frontend uses Vite's environment variable system (`VITE_*` prefix) for runtime configuration.
## What's inside?
This Turborepo includes the following packages/apps:
### Apps and Packages
### Apps
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
- `frontend`: a [SolidJS](https://www.solidjs.com/) app with CRUD UI for persons and pets
- `backend`: a [Fastify](https://fastify.dev/) REST API with PostgreSQL and Kysely ORM
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Packages
- `@olli/api-client-backend`: Auto-generated TypeScript API client based on backend's OpenAPI spec
- `@olli/ts-config`: Shared TypeScript configurations
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
- [Biome](https://biomejs.dev/) for code formatting and linting
- [Kysely](https://kysely.dev/) for type-safe SQL queries
- [Vitest](https://vitest.dev/) for testing
## Development Workflow
### Initial Setup
1. **Copy environment files:**
```sh
cp .env.example .env
cp apps/frontend/.env.example apps/frontend/.env
cp apps/backend/.env.example apps/backend/.env
```
2. **Start local dependencies (PostgreSQL):**
```sh
docker compose up -d
```
- PGAdmin: http://localhost:5050
3. **Install dependencies:**
```sh
pnpm install
```
### Running Development Servers
The recommended way to develop is using Turborepo's watch mode:
```sh
pnpm run dev:watch
```
Or with global Turbo:
```sh
turbo watch dev:watch export-openapi generate
```
> `turbo watch` only works for non-persistent tasks. So `dev` tasks are not affected, therefore we need to run the `dev:watch` task here, so the apps actually watch changes by themselves.
#### What `dev:watch` Does
This single command orchestrates the entire development workflow:
1. **Starts Development Servers** (`dev:watch` task):
- Backend: Runs on http://localhost:3000 with nodemon for auto-restart
- Frontend: Runs on http://localhost:5173 with Vite HMR
2. **Watches for API Changes** (`export-openapi` task):
- Monitors backend source files (`src/**/*.ts`)
- When API schemas change, automatically exports updated OpenAPI spec to `packages/api-client-backend/openapi.json`
3. **Regenerates API Client** (`generate` task):
- Watches the OpenAPI spec file
- When spec changes, regenerates TypeScript client in `packages/api-client-backend/generated/`
- Thanks to PNPM workspace linking, frontend immediately sees the updated types
4. **Updates Frontend** (automatic via Vite HMR):
- Vite detects changes in workspace dependencies
- Frontend auto-refreshes with new API types and methods
**The Flow:**
```
Backend code change
Nodemon restarts backend server
Turbo watch detects file change
export-openapi script runs → generates openapi.json
Turbo runs generate task → regenerates API client
Vite HMR detects api-client-backend change
Frontend reloads with updated types ✨
```
This workflow ensures **zero manual steps** - just change your API schema and watch it propagate through the entire stack!
### Alternative Development Commands
Develop all apps and packages:
```sh
turbo dev
```
Develop a specific package:
```sh
turbo dev --filter=backend
turbo dev --filter=frontend
```
### Build
To build all apps and packages, run the following command:
```
cd my-turborepo
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
Build all apps and packages:
```sh
turbo build
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo build
yarn dlx turbo build
pnpm exec turbo build
```
You can build a specific package by using a [filter](https://turborepo.com/docs/crafting-your-repository/running-tasks#using-filters):
```
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
turbo build --filter=docs
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo build --filter=docs
yarn exec turbo build --filter=docs
pnpm exec turbo build --filter=docs
Build a specific package:
```sh
turbo build --filter=backend
```
### Develop
### Testing
To develop all apps and packages, run the following command:
```
cd my-turborepo
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
turbo dev
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo dev
yarn exec turbo dev
pnpm exec turbo dev
Run all tests:
```sh
turbo test
```
You can develop a specific package by using a [filter](https://turborepo.com/docs/crafting-your-repository/running-tasks#using-filters):
```
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
turbo dev --filter=web
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo dev --filter=web
yarn exec turbo dev --filter=web
pnpm exec turbo dev --filter=web
Run tests in watch mode:
```sh
turbo test:watch
```
### Remote Caching
> [!TIP]
> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
Turborepo can use a technique known as [Remote Caching](https://turborepo.com/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
## Project Structure
```
cd my-turborepo
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
turbo login
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo login
yarn exec turbo login
pnpm exec turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
```
# With [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation) installed (recommended)
turbo link
# Without [global `turbo`](https://turborepo.com/docs/getting-started/installation#global-installation), use your package manager
npx turbo link
yarn exec turbo link
pnpm exec turbo link
apps/
backend/ # Fastify API server
src/
features/ # Feature-based organization
demo/
*.routes.ts # API route handlers
*.schema.ts # Zod/TypeBox schemas
*.repository.ts
db/ # Database setup and migrations
scripts/
export-openapi.ts # OpenAPI spec generator
frontend/ # SolidJS web app
src/
person-crud.tsx # Person management UI
pet-crud.tsx # Pet management UI
api.ts # Configured API client
packages/
api-client-backend/ # Generated API client
generated/ # Auto-generated (don't edit!)
openapi.json # Source of truth from backend
```
## Useful Links

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'kysely-ctl';
import { getConfig } from '../src/config/config.js';
import { createDatabaseDialect } from '../src/db/config.js';
const config = getConfig();
export default defineConfig({
dialect: createDatabaseDialect(config.db),
migrations: {
migrationFolder: '../src/db/migrations',
},
});

14
apps/backend/.env.example Normal file
View File

@ -0,0 +1,14 @@
PORT_INSPECT=3103
ENV=DEV
PINO_LOG_LEVEL=trace
PORT=3003
HOST=0.0.0.0
# Database credentials (should match root .env)
DB_TYPE=postgres
DB_HOST=localhost
DB_PORT=5432
DB_USER=admin
DB_PASSWORD=testing123
DB_NAME=backend

54
apps/backend/Dockerfile Normal file
View File

@ -0,0 +1,54 @@
ARG NODE_VERSION=24
ARG TURBO_VERSION=2.6.3
FROM node:${NODE_VERSION}-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS prepare
# Set working directory
WORKDIR /app
RUN pnpm install -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune @olli/backend --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS builder
WORKDIR /app
RUN pnpm install -g turbo@${TURBO_VERSION}
# First install dependencies (as they change less often)
COPY --from=prepare /app/out/json/ .
COPY --from=prepare /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile
# Build the project and its dependencies
COPY --from=prepare /app/out/full/ .
RUN turbo build --filter=@olli/backend
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN groupadd --system --gid 1001 backend
RUN useradd --system --uid 1001 --gid backend backend
# Copy package files and install production dependencies only
COPY --from=prepare /app/out/json/ .
COPY --from=prepare /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --prod --frozen-lockfile
# Copy built application
COPY --from=builder --chown=backend:backend /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder --chown=backend:backend /app/apps/backend/package.json ./apps/backend/package.json
USER backend
# Set default environment variables (can be overridden)
ENV NODE_ENV=production
ENV PORT=80
EXPOSE 80
CMD ["node", "apps/backend/dist/index.js"]

78
apps/backend/README.md Normal file
View File

@ -0,0 +1,78 @@
# Pegasus typescript template
Template for typescript, node.js projects in Pegasus.
The template defines typescript settings, folder structure and testing setup.
TBD: to use on new project we could just copy the whole repo and "search and replace" `typescript-template` with the new projects name.
- But maybe there is a cleaner method?
## Setup
Set up your machine as described in [dev-environment - setup](https://gitlab.com/fdtvg/alicorn/dev-environment#setup).
## Coding standards
### Cases
For repositories, files and routes we use `kebab-case`.
For variables and functions we use `camelCase`.
For classes, types and interfaces we use `PascalCase`.
For global constants we use `SCREAMING_SNAKE_CASE`.
### Biome.js
Uses rules defined in biome.json for linting & formatting.
### Husky
Uses git hooks to automatically lint & format on every commit.
If you want to skip hooks to commit anything that's WIP, use the "-n" flag (--no-verify).
## Run locally
### Bare metal
We expect node.js v22. Run `npm install` before.
Scripts that can be used bare metal:
- `npm run typecheck`
- `npm run typecheck:dev`
- `npm run test:unit`
### Docker
**Option 1:** run the app with [dev-environment](https://gitlab.com/fdtvg/alicorn/dev-environment)
**Option 2:** run the included test docker-compose:
1. Have docker and compose installed.
2. Create `.env` file in root of repo with content
```
ALICORN_NPM_TOKEN=<YOUR_TOKEN>
```
replace `<YOUR_TOKEN>` with an access token as described in [dev-environment](https://gitlab.com/fdtvg/alicorn/dev-environment)
3. Choose one of the Scripts to start docker compose:
- `npm run test:docker` (starts `test:dev`)
- `npm run test:docker:sh` (starts into interactive shell)
Inside the docker container you can also use following scripts:
- `npm run test` (unit and integration tests)
- `npm run test:dev` (watch and inspect/debug)
- `npm run test:integration`
- `npm run coverage`
## Discussion points
- `src/global` holds global plugins like redis currently
- was named `src/plugins` previously, but everything is a plugin in fastify, so too ambiguous in my opinion
- `src/shared` could be an alternative name like in vastproxy-ts
- other names?
- Folderstructure is currently "by feature" and not "by type"
- I prefer "by feature"
- I know Philipp prefers "by type"
- see also: https://softwareengineering.stackexchange.com/questions/338597/folder-by-type-or-folder-by-feature
- test naming scheme `*.(unit|integration).test.ts`
- could switch to `*.test.(unit|integration).ts`: test will stick together sorted alphabetically, but vscode default config would not detect them as test file.
- other naming other structure?
- test location currently with source files
- alternative: rebuild `src` folder structure under `test` have tests there. Less clutter in `src`, but tests are more far away -> more cumbersome when doing changes where you have to adjust/add tests
- integration tests use real redis, mongodb etc.
- that means integration tests can only be executed in docker-compose or other environments where real databases, etc. are configured
- we could instead always mock, so we could execute integration locally
- more work
- not testing the real thing

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

@ -0,0 +1,56 @@
{
"name": "@olli/backend",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"type": "module",
"scripts": {
"dev": "source .env && tsx -r tsconfig-paths/register --inspect=0.0.0.0:$PORT_INSPECT src/index.ts | pino-pretty -i component,retention,logId",
"dev:watch": "nodemon -e ts --exec 'source .env && tsx -r tsconfig-paths/register --inspect=0.0.0.0:$PORT_INSPECT src/index.ts' | pino-pretty -i component,retention,logId",
"build": "tsc",
"start": "node dist/index.js",
"check-types": "tsc --noEmit",
"migrate:up": "kysely migrate:up",
"migrate:down": "kysely migrate:down",
"test": "vitest run",
"test:watch": "vitest --watch",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"export-openapi": "tsx scripts/export-openapi.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cors": "^11.0.1",
"@fastify/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.2",
"@fastify/type-provider-typebox": "^6.1.0",
"env-schema": "^6.0.1",
"fastify": "^5.2.2",
"fastify-plugin": "^5.1.0",
"kysely": "^0.28.8",
"pg": "^8.16.3",
"pino": "^10.1.0",
"typebox": "^1.0.61"
},
"devDependencies": {
"@olli/ts-config": "workspace:*",
"@testcontainers/postgresql": "^11.10.0",
"@types/node": "^22.13.17",
"@types/pg": "^8.15.6",
"@vitest/coverage-v8": "^3.1.2",
"kysely-ctl": "^0.19.0",
"pino-pretty": "^13.0.0",
"testcontainers": "^11.10.0",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.1.2"
},
"lint-staged": {
"**/*.{ts,tsx,mts,cts,js,cjs,mjs,json,jsonc}": [
"biome check --write --no-errors-on-unmatched"
]
}
}

View File

@ -0,0 +1,34 @@
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { getConfig } from '../src/config/config.js';
import { getServer } from '../src/server.js';
async function exportOpenAPI() {
console.log('Exporting OpenAPI spec...');
const config = getConfig();
const server = getServer(config);
await server.ready();
// Get the OpenAPI spec from Fastify swagger plugin
const spec = server.swagger();
// Write to packages/api-client-backend
const outputPath = resolve(
import.meta.dirname,
'../../../packages/api-client-backend/openapi.json'
);
await writeFile(outputPath, JSON.stringify(spec, null, 2));
console.log(`✓ OpenAPI spec exported to ${outputPath}`);
await server.close();
process.exit(0);
}
exportOpenAPI().catch((error) => {
console.error('Failed to export OpenAPI spec:', error);
process.exit(1);
});

View File

@ -0,0 +1,80 @@
import envSchema from 'env-schema';
import { pino } from 'pino';
import { type Static, Type } from 'typebox';
import packageJson from '../../package.json' with { type: 'json' };
const schema = Type.Object({
TZ: Type.Optional(Type.String()),
ENV: Type.Union([Type.Literal('DEV'), Type.Literal('UAT'), Type.Literal('PROD')]),
PINO_LOG_LEVEL: Type.String({ default: 'info' }),
HOST: Type.String({ default: '0.0.0.0' }),
PORT: Type.Integer({ default: 3000 }),
TEST: Type.String({ default: 'default test value' }),
DB_TYPE: Type.Union([Type.Literal('postgres')]),
DB_HOST: Type.String(),
DB_PORT: Type.Integer({ default: 5432 }),
DB_USER: Type.String(),
DB_PASSWORD: Type.Optional(Type.String()),
DB_NAME: Type.String({ default: 'backend' }),
});
type EnvSchema = Static<typeof schema>;
function getEnv(): EnvSchema {
return envSchema({
schema: schema,
dotenv: {
quiet: true,
debug: false,
path: '.env',
},
});
}
export function createLogger(level: string) {
return pino({
level,
timestamp: pino.stdTimeFunctions.isoTime,
});
}
function getConfig() {
const env = getEnv();
const logger = createLogger(env.PINO_LOG_LEVEL);
const config = {
name: packageJson.name,
version: packageJson.version,
env: env.ENV,
fastify: {
httpOptions: {
loggerInstance: logger,
},
listenOptions: {
host: env.HOST,
port: env.PORT,
},
},
db: {
type: env.DB_TYPE,
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
},
application: {},
};
logger.debug(
{
config,
},
'Current config'
);
return config;
}
export { getEnv, getConfig };

View File

@ -0,0 +1,24 @@
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import type {
FastifyInstance,
FastifyPluginAsync,
FastifyPluginOptions,
RawReplyDefaultExpression,
RawRequestDefaultExpression,
RawServerDefault,
} from 'fastify';
import type { Logger } from 'pino';
import type { getConfig } from './config.ts';
export type Config = ReturnType<typeof getConfig>;
export type Plugin<Options extends FastifyPluginOptions = Record<never, never>> =
FastifyPluginAsync<Options, RawServerDefault, TypeBoxTypeProvider, Logger>;
export type Server = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
Logger,
TypeBoxTypeProvider
>;

View File

@ -0,0 +1,29 @@
import { type Dialect, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
export interface DatabaseConfig {
type: 'postgres';
host: string;
port: number;
user: string;
password?: string;
database: string;
}
export function createDatabaseDialect(config: DatabaseConfig): Dialect {
switch (config.type) {
case 'postgres':
return new PostgresDialect({
pool: new Pool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
max: 10,
}),
});
default:
throw new Error(`Unsupported database type: ${config.type}`);
}
}

View File

@ -0,0 +1,42 @@
import type { Kysely } from 'kysely';
import { sql } from 'kysely';
import type { Database } from '../types.js';
export async function up(db: Kysely<Database>): Promise<void> {
// Create person table
await db.schema
.createTable('person')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('first_name', 'varchar(255)', (col) => col.notNull())
.addColumn('last_name', 'varchar(255)')
.addColumn('gender', 'varchar(10)', (col) =>
col.notNull().check(sql`gender IN ('man', 'woman', 'other')`)
)
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
)
.addColumn('metadata', 'jsonb', (col) => col.notNull())
.execute();
// Create pet table
await db.schema
.createTable('pet')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('name', 'varchar(255)', (col) => col.notNull())
.addColumn('owner_id', 'integer', (col) =>
col.notNull().references('person.id').onDelete('cascade')
)
.addColumn('species', 'varchar(10)', (col) =>
col.notNull().check(sql`species IN ('dog', 'cat')`)
)
.execute();
// Create index on pet.owner_id for better query performance
await db.schema.createIndex('pet_owner_id_index').on('pet').column('owner_id').execute();
}
export async function down(db: Kysely<Database>): Promise<void> {
// Drop tables in reverse order (drop dependent tables first)
await db.schema.dropTable('pet').execute();
await db.schema.dropTable('person').execute();
}

View File

@ -0,0 +1,6 @@
import type { PersonTable, PetTable } from '@/features/demo/demo.db-schema.js';
export interface Database {
person: PersonTable;
pet: PetTable;
}

View File

@ -0,0 +1,67 @@
import type {
ColumnType,
Generated,
Insertable,
JSONColumnType,
Selectable,
Updateable,
} from 'kysely';
// This interface describes the `person` table to Kysely. Table
// interfaces should only be used in the `Database` type
// and never as a result type of a query! See the `Person`,
// `NewPerson` and `PersonUpdate` types below.
export interface PersonTable {
// Columns that are generated by the database should be marked
// using the `Generated` type. This way they are automatically
// made optional in inserts and updates.
id: Generated<number>;
first_name: string;
gender: 'man' | 'woman' | 'other';
// If the column is nullable in the database, make its type nullable.
// Don't use optional properties. Optionality is always determined
// automatically by Kysely.
last_name: string | null;
// You can specify a different type for each operation (select, insert and
// update) using the `ColumnType<SelectType, InsertType, UpdateType>`
// wrapper. Here we define a column `created_at` that is selected as
// a `Date`, can optionally be provided as a `string` in inserts and
// can never be updated:
created_at: ColumnType<Date, string | undefined, never>;
// You can specify JSON columns using the `JSONColumnType` wrapper.
// It is a shorthand for `ColumnType<T, string, string>`, where T
// is the type of the JSON object/array retrieved from the database,
// and the insert and update types are always `string` since you're
// always stringifying insert/update values.
metadata: JSONColumnType<{
login_at: string;
ip: string | null;
agent: string | null;
plan: 'free' | 'premium';
}>;
}
// You should not use the table schema interfaces directly. Instead, you should
// use the `Selectable`, `Insertable` and `Updateable` wrappers. These wrappers
// make sure that the correct types are used in each operation.
//
// Most of the time you should trust the type inference and not use explicit
// types at all. These types can be useful when typing function arguments.
export type Person = Selectable<PersonTable>;
export type NewPerson = Insertable<PersonTable>;
export type PersonUpdate = Updateable<PersonTable>;
export interface PetTable {
id: Generated<number>;
name: string;
owner_id: number;
species: 'dog' | 'cat';
}
export type Pet = Selectable<PetTable>;
export type NewPet = Insertable<PetTable>;
export type PetUpdate = Updateable<PetTable>;

View File

@ -0,0 +1,29 @@
import fp from 'fastify-plugin';
import type { Plugin } from '@/config/types.js';
import { DemoRepository } from './demo.repository.js';
import { personRoutes } from './person.routes.js';
import { petRoutes } from './pet.routes.js';
declare module 'fastify' {
interface FastifyInstance {
demoRepository: DemoRepository;
}
}
/**
* Demo plugin that registers all demo-related routes and functionality
*/
const _demoPlugin: Plugin = async (server) => {
server.decorate('demoRepository', new DemoRepository(server.db));
// Register person routes
await server.register(personRoutes, { prefix: '/demo' });
// Register pet routes
await server.register(petRoutes, { prefix: '/demo' });
};
export const demoPlugin = fp(_demoPlugin, {
name: 'demo',
dependencies: ['kysely'],
});

View File

@ -0,0 +1,164 @@
import type { Kysely } from 'kysely';
import type { Database } from '@/db/types.js';
import type { NewPerson, NewPet, Person, PersonUpdate, Pet, PetUpdate } from './demo.db-schema.js';
export class DemoRepository {
constructor(private db: Kysely<Database>) {}
// ==========================================
// Person Methods
// ==========================================
async findPersonById(id: number) {
return await this.db
.selectFrom('person')
.where('id', '=', id)
.selectAll()
.executeTakeFirst();
}
async findPeople(criteria: Partial<Person>) {
let query = this.db.selectFrom('person');
if (criteria.id) {
query = query.where('id', '=', criteria.id);
}
if (criteria.first_name) {
query = query.where('first_name', '=', criteria.first_name);
}
if (criteria.last_name !== undefined) {
query = query.where(
'last_name',
criteria.last_name === null ? 'is' : '=',
criteria.last_name
);
}
if (criteria.gender) {
query = query.where('gender', '=', criteria.gender);
}
if (criteria.created_at) {
query = query.where('created_at', '=', criteria.created_at);
}
return await query.selectAll().execute();
}
async createPerson(person: NewPerson) {
return await this.db
.insertInto('person')
.values(person)
.returningAll()
.executeTakeFirstOrThrow();
}
async updatePerson(id: number, updateWith: PersonUpdate) {
await this.db.updateTable('person').set(updateWith).where('id', '=', id).execute();
}
async deletePerson(id: number) {
return await this.db
.deleteFrom('person')
.where('id', '=', id)
.returningAll()
.executeTakeFirst();
}
async findPersonWithPets(id: number) {
const person = await this.findPersonById(id);
if (!person) return null;
const pets = await this.db
.selectFrom('pet')
.where('owner_id', '=', id)
.selectAll()
.execute();
return { ...person, pets };
}
// ==========================================
// Pet Methods
// ==========================================
async findPetById(id: number) {
return await this.db.selectFrom('pet').where('id', '=', id).selectAll().executeTakeFirst();
}
async findPets(criteria: Partial<Pet>) {
let query = this.db.selectFrom('pet');
if (criteria.id) {
query = query.where('id', '=', criteria.id);
}
if (criteria.name) {
query = query.where('name', '=', criteria.name);
}
if (criteria.owner_id) {
query = query.where('owner_id', '=', criteria.owner_id);
}
if (criteria.species) {
query = query.where('species', '=', criteria.species);
}
return await query.selectAll().execute();
}
async createPet(pet: NewPet) {
return await this.db.insertInto('pet').values(pet).returningAll().executeTakeFirstOrThrow();
}
async updatePet(id: number, updateWith: PetUpdate) {
await this.db.updateTable('pet').set(updateWith).where('id', '=', id).execute();
}
async deletePet(id: number) {
return await this.db
.deleteFrom('pet')
.where('id', '=', id)
.returningAll()
.executeTakeFirst();
}
async findPetWithOwner(id: number) {
return await this.db
.selectFrom('pet')
.innerJoin('person', 'person.id', 'pet.owner_id')
.where('pet.id', '=', id)
.select([
'pet.id as pet_id',
'pet.name as pet_name',
'pet.species',
'person.id as owner_id',
'person.first_name as owner_first_name',
'person.last_name as owner_last_name',
'person.gender as owner_gender',
'person.created_at as owner_created_at',
'person.metadata as owner_metadata',
])
.executeTakeFirst()
.then((result) => {
if (!result) return null;
return {
pet: {
id: result.pet_id,
name: result.pet_name,
species: result.species,
owner_id: result.owner_id,
},
owner: {
id: result.owner_id,
first_name: result.owner_first_name,
last_name: result.owner_last_name,
gender: result.owner_gender,
created_at: result.owner_created_at,
metadata: result.owner_metadata,
},
};
});
}
}

View File

@ -0,0 +1,133 @@
import type { Static } from 'typebox';
import { Type } from 'typebox';
// ==========================================
// Shared Metadata Schema
// ==========================================
const MetadataSchema = Type.Object({
login_at: Type.String(),
ip: Type.Union([Type.String(), Type.Null()]),
agent: Type.Union([Type.String(), Type.Null()]),
plan: Type.Union([Type.Literal('free'), Type.Literal('premium')]),
});
// ==========================================
// Response Schemas
// ==========================================
export const PersonResponseSchema = Type.Object({
id: Type.Number(),
first_name: Type.String({ description: 'The first name of the person' }),
last_name: Type.Union([Type.String(), Type.Null()]),
gender: Type.Union([Type.Literal('man'), Type.Literal('woman'), Type.Literal('other')]),
height: Type.Optional(Type.Number()),
weight: Type.Optional(Type.Number()),
created_at: Type.String({ format: 'date-time' }),
metadata: MetadataSchema,
});
export const PetResponseSchema = Type.Object({
id: Type.Number(),
name: Type.String(),
owner_id: Type.Number(),
species: Type.Union([Type.Literal('dog'), Type.Literal('cat')]),
});
export const PersonWithPetsResponseSchema = Type.Object({
id: Type.Number(),
first_name: Type.String(),
last_name: Type.Union([Type.String(), Type.Null()]),
gender: Type.Union([Type.Literal('man'), Type.Literal('woman'), Type.Literal('other')]),
created_at: Type.String({ format: 'date-time' }),
metadata: MetadataSchema,
pets: Type.Array(PetResponseSchema),
});
export const PetWithOwnerResponseSchema = Type.Object({
pet: PetResponseSchema,
owner: PersonResponseSchema,
});
export const ErrorResponseSchema = Type.Object({
message: Type.String(),
});
// ==========================================
// Person Input Schemas
// ==========================================
export const CreatePersonSchema = Type.Object({
first_name: Type.String({ minLength: 1 }),
last_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
gender: Type.Union([Type.Literal('man'), Type.Literal('woman'), Type.Literal('other')]),
metadata: MetadataSchema,
});
export const UpdatePersonSchema = Type.Object({
first_name: Type.Optional(Type.String({ minLength: 1 })),
last_name: Type.Optional(Type.Union([Type.String(), Type.Null()])),
gender: Type.Optional(
Type.Union([Type.Literal('man'), Type.Literal('woman'), Type.Literal('other')])
),
metadata: Type.Optional(MetadataSchema),
});
export const PersonIdParamSchema = Type.Object({
id: Type.Number({ minimum: 1 }),
});
export const PersonQuerySchema = Type.Object({
first_name: Type.Optional(Type.String()),
last_name: Type.Optional(Type.String()),
gender: Type.Optional(
Type.Union([Type.Literal('man'), Type.Literal('woman'), Type.Literal('other')])
),
});
// ==========================================
// Pet Input Schemas
// ==========================================
export const CreatePetSchema = Type.Object({
name: Type.String({ minLength: 1 }),
owner_id: Type.Number({ minimum: 1 }),
species: Type.Union([Type.Literal('dog'), Type.Literal('cat')]),
});
export const UpdatePetSchema = Type.Object({
name: Type.Optional(Type.String({ minLength: 1 })),
owner_id: Type.Optional(Type.Number({ minimum: 1 })),
species: Type.Optional(Type.Union([Type.Literal('dog'), Type.Literal('cat')])),
});
export const PetIdParamSchema = Type.Object({
id: Type.Number({ minimum: 1 }),
});
export const PetQuerySchema = Type.Object({
name: Type.Optional(Type.String()),
owner_id: Type.Optional(Type.Number()),
species: Type.Optional(Type.Union([Type.Literal('dog'), Type.Literal('cat')])),
});
// ==========================================
// Type Exports
// ==========================================
export type CreatePersonInput = Static<typeof CreatePersonSchema>;
export type UpdatePersonInput = Static<typeof UpdatePersonSchema>;
export type CreatePetInput = Static<typeof CreatePetSchema>;
export type UpdatePetInput = Static<typeof UpdatePetSchema>;
// ==========================================
// Helper Functions
// ==========================================
export function serializePersonInput(input: CreatePersonInput) {
return {
...input,
metadata: JSON.stringify(input.metadata),
};
}
export function serializePersonUpdateInput(input: UpdatePersonInput) {
return {
...input,
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
};
}

View File

@ -0,0 +1,149 @@
import type { Server } from '@/config/types.js';
import type { Person } from './demo.db-schema.js';
import {
CreatePersonSchema,
ErrorResponseSchema,
PersonIdParamSchema,
PersonQuerySchema,
PersonResponseSchema,
PersonWithPetsResponseSchema,
serializePersonInput,
serializePersonUpdateInput,
UpdatePersonSchema,
} from './demo.schema.js';
function serializePerson(person: Person) {
return {
...person,
created_at: person.created_at.toISOString(),
};
}
export async function personRoutes(server: Server) {
const repo = server.demoRepository;
// Create person
server.route({
method: 'POST',
url: '/persons',
schema: {
tags: ['person'],
body: CreatePersonSchema,
response: {
201: PersonResponseSchema,
},
},
handler: async (request, reply) => {
const person = await repo.createPerson(serializePersonInput(request.body));
return reply.code(201).send(serializePerson(person));
},
});
// Get person by ID
server.route({
method: 'GET',
url: '/persons/:id',
schema: {
tags: ['person'],
params: PersonIdParamSchema,
response: {
200: PersonResponseSchema,
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const person = await repo.findPersonById(request.params.id);
if (!person) {
return reply.code(404).send({ message: 'Person not found' });
}
return reply.send(serializePerson(person));
},
});
// Get person with pets
server.route({
method: 'GET',
url: '/persons/:id/with-pets',
schema: {
tags: ['person'],
params: PersonIdParamSchema,
response: {
200: PersonWithPetsResponseSchema,
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const result = await repo.findPersonWithPets(request.params.id);
if (!result) {
return reply.code(404).send({ message: 'Person not found' });
}
return reply.send({
...serializePerson(result),
pets: result.pets,
});
},
});
// List persons with optional filters
server.route({
method: 'GET',
url: '/persons',
schema: {
tags: ['person'],
querystring: PersonQuerySchema,
response: {
200: { type: 'array', items: PersonResponseSchema },
},
},
handler: async (request, reply) => {
const persons = await repo.findPeople(request.query);
return reply.send(persons.map(serializePerson));
},
});
// Update person
server.route({
method: 'PATCH',
url: '/persons/:id',
schema: {
tags: ['person'],
params: PersonIdParamSchema,
body: UpdatePersonSchema,
response: {
204: { type: 'null' },
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
// Check if person exists
const existing = await repo.findPersonById(request.params.id);
if (!existing) {
return reply.code(404).send({ message: 'Person not found' });
}
await repo.updatePerson(request.params.id, serializePersonUpdateInput(request.body));
return reply.code(204).send();
},
});
// Delete person
server.route({
method: 'DELETE',
url: '/persons/:id',
schema: {
tags: ['person'],
params: PersonIdParamSchema,
response: {
204: { type: 'null' },
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const deleted = await repo.deletePerson(request.params.id);
if (!deleted) {
return reply.code(404).send({ message: 'Person not found' });
}
return reply.code(204).send();
},
});
}

View File

@ -0,0 +1,147 @@
import type { Server } from '@/config/types.js';
import type { Person } from './demo.db-schema.js';
import {
CreatePetSchema,
ErrorResponseSchema,
PetIdParamSchema,
PetQuerySchema,
PetResponseSchema,
PetWithOwnerResponseSchema,
UpdatePetSchema,
} from './demo.schema.js';
function serializePerson(person: Person) {
return {
...person,
created_at: person.created_at.toISOString(),
};
}
export async function petRoutes(server: Server) {
const repo = server.demoRepository;
// Create pet
server.route({
method: 'POST',
url: '/pets',
schema: {
tags: ['pet'],
body: CreatePetSchema,
response: {
201: PetResponseSchema,
},
},
handler: async (request, reply) => {
const pet = await repo.createPet(request.body);
return reply.code(201).send(pet);
},
});
// Get pet by ID
server.route({
method: 'GET',
url: '/pets/:id',
schema: {
tags: ['pet'],
params: PetIdParamSchema,
response: {
200: PetResponseSchema,
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const pet = await repo.findPetById(request.params.id);
if (!pet) {
return reply.code(404).send({ message: 'Pet not found' });
}
return reply.send(pet);
},
});
// Get pet with owner
server.route({
method: 'GET',
url: '/pets/:id/with-owner',
schema: {
tags: ['pet'],
params: PetIdParamSchema,
response: {
200: PetWithOwnerResponseSchema,
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const result = await repo.findPetWithOwner(request.params.id);
if (!result) {
return reply.code(404).send({ message: 'Pet not found' });
}
return reply.send({
pet: result.pet,
owner: serializePerson(result.owner),
});
},
});
// List pets with optional filters
server.route({
method: 'GET',
url: '/pets',
schema: {
tags: ['pet'],
querystring: PetQuerySchema,
response: {
200: { type: 'array', items: PetResponseSchema },
},
},
handler: async (request, reply) => {
const pets = await repo.findPets(request.query);
return reply.send(pets);
},
});
// Update pet
server.route({
method: 'PATCH',
url: '/pets/:id',
schema: {
tags: ['pet'],
params: PetIdParamSchema,
body: UpdatePetSchema,
response: {
204: { type: 'null' },
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
// Check if pet exists
const existing = await repo.findPetById(request.params.id);
if (!existing) {
return reply.code(404).send({ message: 'Pet not found' });
}
await repo.updatePet(request.params.id, request.body);
return reply.code(204).send();
},
});
// Delete pet
server.route({
method: 'DELETE',
url: '/pets/:id',
schema: {
tags: ['pet'],
params: PetIdParamSchema,
response: {
204: { type: 'null' },
404: ErrorResponseSchema,
},
},
handler: async (request, reply) => {
const deleted = await repo.deletePet(request.params.id);
if (!deleted) {
return reply.code(404).send({ message: 'Pet not found' });
}
return reply.code(204).send();
},
});
}

View File

@ -0,0 +1,52 @@
import type { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
export interface StatusPluginOptions {
env: 'DEV' | 'UAT' | 'PROD';
}
/**
* Status plugin for healthchecks etc.
*/
const _statusPlugin: FastifyPluginAsync = async (fastify) => {
fastify.route({
method: 'GET',
url: '/status',
schema: {
tags: ['status'],
response: {
200: {
type: 'object',
properties: {
msg: {
type: 'string',
example: 'Server is up and running fine!',
},
},
},
'5xx': {
type: 'object',
properties: {
msg: {
type: 'string',
example: 'Server is not ready.',
},
},
},
},
},
handler: async (req, reply) => {
try {
// Check database connection with a simple query
await fastify.db.selectNoFrom((eb) => eb.val(1).as('health')).execute();
} catch (err) {
req.log.warn({ err }, 'Server is not ready');
return reply.code(503).send({ msg: 'Server is not ready.' });
}
return reply.code(200).send({ msg: 'Server is up and running fine!' });
},
});
};
export const statusPlugin = fp(_statusPlugin, { name: 'status' });

View File

@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { Kysely } from 'kysely';
import { createDatabaseDialect, type DatabaseConfig } from '@/db/config.js';
import type { Database } from '@/db/types.js';
declare module 'fastify' {
interface FastifyInstance {
db: Kysely<Database>;
}
}
export type KyselyPluginOptions = DatabaseConfig;
/**
* Kysely plugin generates an OpenAPI spec and hosts a UI for browsing and trying the API
*/
async function _kyselyPlugin(fastify: FastifyInstance, options: KyselyPluginOptions) {
const dialect = createDatabaseDialect(options);
const db = new Kysely<Database>({
dialect,
});
fastify.decorate('db', db);
fastify.addHook('onClose', async (instance) => {
await instance.db.destroy();
instance.log.info('Database connection closed');
});
}
export const kyselyPlugin = fp(_kyselyPlugin, { name: 'kysely' });

View File

@ -0,0 +1,48 @@
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
export interface SwaggerPluginOptions {
env: 'DEV' | 'UAT' | 'PROD';
name: string;
version: string;
}
/**
* Swagger plugin generates an OpenAPI spec and hosts a UI for browsing and trying the API
*/
async function _swaggerPlugin(fastify: FastifyInstance, options: SwaggerPluginOptions) {
const titlePrefix = options.env === 'PROD' ? '' : `${options.env} `;
await fastify.register(swagger, {
openapi: {
openapi: '3.1.0',
info: {
title: `${titlePrefix}${options.name}`,
version: options.version,
},
},
});
await fastify.register(swaggerUi, {
routePrefix: '/documentation',
theme: {
title: `${titlePrefix}${options.name}`,
},
uiConfig: {
// docExpansion: 'full',
deepLinking: false,
},
// uiHooks: {
// onRequest: function (request, reply, next) { next() },
// preHandler: function (request, reply, next) { next() }
// },
staticCSP: true,
// transformStaticCSP: (header) => header,
// transformSpecification: (swaggerObject, request, reply) => { return swaggerObject },
transformSpecificationClone: true,
});
}
export const swaggerPlugin = fp(_swaggerPlugin, { name: 'swagger' });

12
apps/backend/src/index.ts Normal file
View File

@ -0,0 +1,12 @@
import { getConfig } from '@/config/config.js';
import { getServer } from '@/server.js';
const main = async () => {
const config = getConfig();
const server = getServer(config);
const address = await server.listen(config.fastify.listenOptions);
server.log.info(`Server running at: ${address}`);
};
main();

View File

@ -0,0 +1,40 @@
import cors from '@fastify/cors';
import { type TypeBoxTypeProvider, TypeBoxValidatorCompiler } from '@fastify/type-provider-typebox';
import Fastify from 'fastify';
import fp from 'fastify-plugin';
import type { Config, Plugin, Server } from './config/types.ts';
import { demoPlugin } from './features/demo/demo.plugin.js';
import { statusPlugin } from './features/status.js';
import { kyselyPlugin } from './global/kysely.js';
import { swaggerPlugin } from './global/swagger.js';
/**
* Main configuration of the fastify webserver.
*/
const _serverPlugin: Plugin<Config> = async (fastify, config) => {
// globals
fastify.register(cors);
fastify.register(swaggerPlugin, {
env: config.env,
name: config.name,
version: config.version,
});
fastify.register(kyselyPlugin, config.db);
// features
fastify.register(demoPlugin, {});
await fastify.after();
await fastify.register(statusPlugin);
};
const serverPlugin = fp(_serverPlugin, { name: 'server' });
export function getServer(config: Config): Server {
const server = Fastify(config.fastify.httpOptions)
.withTypeProvider<TypeBoxTypeProvider>()
.setValidatorCompiler(TypeBoxValidatorCompiler);
server.register(serverPlugin, config);
return server;
}

View File

@ -0,0 +1,7 @@
declare module 'fastify' {
interface FastifyInstance {}
interface FastifyRequest {}
interface FastifyReply {}
}

View File

@ -0,0 +1,319 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getConfig } from '@/config/config.js';
import type { Server } from '@/config/types.js';
import type { Person, Pet } from '@/features/demo/demo.db-schema.js';
import { getServer } from '@/server.js';
let server: Server;
beforeAll(async () => {
const config = getConfig();
server = getServer(config);
await server.ready();
});
afterAll(async () => {
await server.close();
});
// Helper to create valid metadata
const createMetadata = () => ({
login_at: new Date().toISOString(),
ip: '192.168.1.1',
agent: 'Mozilla/5.0',
plan: 'premium' as const,
});
describe('Person CRUD', () => {
it('should create a person', async () => {
const response = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'John',
last_name: 'Doe',
gender: 'man',
metadata: createMetadata(),
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.first_name).toBe('John');
expect(body.last_name).toBe('Doe');
expect(body.gender).toBe('man');
expect(body.id).toBeDefined();
expect(body.created_at).toBeDefined();
expect(body.metadata.plan).toBe('premium');
});
it('should get a person by ID', async () => {
// Create a person first
const createResponse = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Jane',
last_name: 'Smith',
gender: 'woman',
metadata: createMetadata(),
},
});
const created = createResponse.json();
// Get the person
const response = await server.inject({
method: 'GET',
url: `/demo/persons/${created.id}`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body).toMatchObject({
id: created.id,
first_name: 'Jane',
last_name: 'Smith',
gender: 'woman',
});
});
it('should return 404 for non-existent person', async () => {
const response = await server.inject({
method: 'GET',
url: '/demo/persons/999999',
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ message: 'Person not found' });
});
it('should list persons', async () => {
// Create multiple persons
await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Alice',
gender: 'woman',
metadata: createMetadata(),
},
});
await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Bob',
gender: 'man',
metadata: createMetadata(),
},
});
const response = await server.inject({
method: 'GET',
url: '/demo/persons',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should filter persons by first_name', async () => {
// Create persons
await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Charlie',
gender: 'man',
metadata: createMetadata(),
},
});
const response = await server.inject({
method: 'GET',
url: '/demo/persons?first_name=Charlie',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.every((p: Person) => p.first_name === 'Charlie')).toBe(true);
});
it('should update a person', async () => {
// Create a person
const createResponse = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Original',
gender: 'other',
metadata: createMetadata(),
},
});
const created = createResponse.json();
// Update the person
const updateResponse = await server.inject({
method: 'PATCH',
url: `/demo/persons/${created.id}`,
payload: {
first_name: 'Updated',
},
});
expect(updateResponse.statusCode).toBe(204);
// Verify the update
const getResponse = await server.inject({
method: 'GET',
url: `/demo/persons/${created.id}`,
});
const updated = getResponse.json();
expect(updated.first_name).toBe('Updated');
expect(updated.gender).toBe('other'); // Should remain unchanged
});
it('should return 404 when updating non-existent person', async () => {
const response = await server.inject({
method: 'PATCH',
url: '/demo/persons/999999',
payload: { first_name: 'Does Not Exist' },
});
expect(response.statusCode).toBe(404);
});
it('should delete a person', async () => {
// Create a person
const createResponse = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'ToDelete',
gender: 'man',
metadata: createMetadata(),
},
});
const created = createResponse.json();
// Delete the person
const deleteResponse = await server.inject({
method: 'DELETE',
url: `/demo/persons/${created.id}`,
});
expect(deleteResponse.statusCode).toBe(204);
// Verify deletion
const getResponse = await server.inject({
method: 'GET',
url: `/demo/persons/${created.id}`,
});
expect(getResponse.statusCode).toBe(404);
});
it('should return 404 when deleting non-existent person', async () => {
const response = await server.inject({
method: 'DELETE',
url: '/demo/persons/999999',
});
expect(response.statusCode).toBe(404);
});
it('should create person with different plans', async () => {
const freePlan = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Free',
gender: 'woman',
metadata: { ...createMetadata(), plan: 'free' },
},
});
expect(freePlan.statusCode).toBe(201);
expect(freePlan.json().metadata.plan).toBe('free');
const premiumPlan = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'Premium',
gender: 'man',
metadata: { ...createMetadata(), plan: 'premium' },
},
});
expect(premiumPlan.statusCode).toBe(201);
expect(premiumPlan.json().metadata.plan).toBe('premium');
});
});
describe('Person with Pets', () => {
it('should get person with their pets', async () => {
// Create a person
const personResponse = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'PetOwner',
gender: 'other',
metadata: createMetadata(),
},
});
const person = personResponse.json();
// Create pets for this person
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Fluffy', species: 'cat', owner_id: person.id },
});
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Rex', species: 'dog', owner_id: person.id },
});
// Get person with pets
const response = await server.inject({
method: 'GET',
url: `/demo/persons/${person.id}/with-pets`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.id).toBe(person.id);
expect(body.first_name).toBe('PetOwner');
expect(Array.isArray(body.pets)).toBe(true);
expect(body.pets.length).toBe(2);
expect(body.pets.map((p: Pet) => p.name).sort()).toEqual(['Fluffy', 'Rex']);
});
it('should return person with empty pets array when they have no pets', async () => {
const personResponse = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name: 'NoPets',
gender: 'man',
metadata: createMetadata(),
},
});
const person = personResponse.json();
const response = await server.inject({
method: 'GET',
url: `/demo/persons/${person.id}/with-pets`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.pets).toEqual([]);
});
});

View File

@ -0,0 +1,323 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getConfig } from '@/config/config.js';
import type { Server } from '@/config/types.js';
import type { Pet } from '@/features/demo/demo.db-schema.js';
import { getServer } from '@/server.js';
let server: Server;
beforeAll(async () => {
const config = getConfig();
server = getServer(config);
await server.ready();
});
afterAll(async () => {
await server.close();
});
// Helper to create valid metadata
const createMetadata = () => ({
login_at: new Date().toISOString(),
ip: '192.168.1.1',
agent: 'Mozilla/5.0',
plan: 'premium' as const,
});
// Helper to create a person
async function createPerson(first_name: string, gender: 'man' | 'woman' | 'other' = 'man') {
const response = await server.inject({
method: 'POST',
url: '/demo/persons',
payload: {
first_name,
gender,
metadata: createMetadata(),
},
});
return response.json();
}
describe('Pet CRUD', () => {
it('should create a pet', async () => {
const owner = await createPerson('PetOwner');
const response = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Buddy', species: 'dog', owner_id: owner.id },
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body).toMatchObject({
name: 'Buddy',
species: 'dog',
owner_id: owner.id,
});
expect(body.id).toBeDefined();
});
it('should get a pet by ID', async () => {
const owner = await createPerson('Owner');
// Create a pet
const createResponse = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Mittens', species: 'cat', owner_id: owner.id },
});
const created = createResponse.json();
// Get the pet
const response = await server.inject({
method: 'GET',
url: `/demo/pets/${created.id}`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body).toMatchObject({
id: created.id,
name: 'Mittens',
species: 'cat',
owner_id: owner.id,
});
});
it('should return 404 for non-existent pet', async () => {
const response = await server.inject({
method: 'GET',
url: '/demo/pets/999999',
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ message: 'Pet not found' });
});
it('should list pets', async () => {
const owner = await createPerson('MultiPetOwner');
// Create multiple pets
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Spot', species: 'dog', owner_id: owner.id },
});
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Whiskers', species: 'cat', owner_id: owner.id },
});
const response = await server.inject({
method: 'GET',
url: '/demo/pets',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should filter pets by species', async () => {
const owner = await createPerson('SpeciesOwner');
// Create pets with different species
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Fido', species: 'dog', owner_id: owner.id },
});
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Kitty', species: 'cat', owner_id: owner.id },
});
const response = await server.inject({
method: 'GET',
url: '/demo/pets?species=dog',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.every((p: Pet) => p.species === 'dog')).toBe(true);
});
it('should filter pets by owner_id', async () => {
const owner1 = await createPerson('Owner1');
const owner2 = await createPerson('Owner2', 'woman');
// Create pets for different owners
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Pet1', species: 'dog', owner_id: owner1.id },
});
await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Pet2', species: 'cat', owner_id: owner2.id },
});
const response = await server.inject({
method: 'GET',
url: `/demo/pets?owner_id=${owner1.id}`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.every((p: Pet) => p.owner_id === owner1.id)).toBe(true);
});
it('should update a pet', async () => {
const owner = await createPerson('UpdateOwner');
// Create a pet
const createResponse = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'OldName', species: 'dog', owner_id: owner.id },
});
const created = createResponse.json();
// Update the pet
const updateResponse = await server.inject({
method: 'PATCH',
url: `/demo/pets/${created.id}`,
payload: { name: 'NewName' },
});
expect(updateResponse.statusCode).toBe(204);
// Verify the update
const getResponse = await server.inject({
method: 'GET',
url: `/demo/pets/${created.id}`,
});
const updated = getResponse.json();
expect(updated.name).toBe('NewName');
expect(updated.species).toBe('dog'); // Should remain unchanged
});
it('should return 404 when updating non-existent pet', async () => {
const response = await server.inject({
method: 'PATCH',
url: '/demo/pets/999999',
payload: { name: 'Does Not Exist' },
});
expect(response.statusCode).toBe(404);
});
it('should delete a pet', async () => {
const owner = await createPerson('DeleteOwner');
// Create a pet
const createResponse = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'ToDelete', species: 'cat', owner_id: owner.id },
});
const created = createResponse.json();
// Delete the pet
const deleteResponse = await server.inject({
method: 'DELETE',
url: `/demo/pets/${created.id}`,
});
expect(deleteResponse.statusCode).toBe(204);
// Verify deletion
const getResponse = await server.inject({
method: 'GET',
url: `/demo/pets/${created.id}`,
});
expect(getResponse.statusCode).toBe(404);
});
it('should return 404 when deleting non-existent pet', async () => {
const response = await server.inject({
method: 'DELETE',
url: '/demo/pets/999999',
});
expect(response.statusCode).toBe(404);
});
});
describe('Pet with Owner', () => {
it('should get pet with owner details', async () => {
const owner = await createPerson('DetailedOwner', 'other');
// Create a pet
const petResponse = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Max', species: 'dog', owner_id: owner.id },
});
const pet = petResponse.json();
// Get pet with owner
const response = await server.inject({
method: 'GET',
url: `/demo/pets/${pet.id}/with-owner`,
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.pet.id).toBe(pet.id);
expect(body.pet.name).toBe('Max');
expect(body.owner).toBeDefined();
expect(body.owner.id).toBe(owner.id);
expect(body.owner.first_name).toBe('DetailedOwner');
expect(body.owner.gender).toBe('other');
});
});
describe('Cascade Delete', () => {
it('should delete all pets when owner is deleted', async () => {
const owner = await createPerson('CascadeOwner');
// Create multiple pets
const pet1Response = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Pet1', species: 'dog', owner_id: owner.id },
});
const pet2Response = await server.inject({
method: 'POST',
url: '/demo/pets',
payload: { name: 'Pet2', species: 'cat', owner_id: owner.id },
});
const pet1 = pet1Response.json();
const pet2 = pet2Response.json();
// Delete the owner
const deleteResponse = await server.inject({
method: 'DELETE',
url: `/demo/persons/${owner.id}`,
});
expect(deleteResponse.statusCode).toBe(204);
// Verify pets are also deleted
const pet1Check = await server.inject({
method: 'GET',
url: `/demo/pets/${pet1.id}`,
});
const pet2Check = await server.inject({
method: 'GET',
url: `/demo/pets/${pet2.id}`,
});
expect(pet1Check.statusCode).toBe(404);
expect(pet2Check.statusCode).toBe(404);
});
});

View File

@ -0,0 +1,91 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Kysely, Migrator } from 'kysely';
import { createDatabaseDialect } from '../src/db/config.js';
import type { Database } from '../src/db/types.js';
let postgresContainer: StartedPostgreSqlContainer;
export async function setup() {
console.log('Starting PostgreSQL testcontainer...');
postgresContainer = await new PostgreSqlContainer('postgres:18')
.withExposedPorts(5432)
.withDatabase('test_db')
.withUsername('test_user')
.withPassword('test_password')
.start();
// Set environment variables for tests to use
process.env.DB_TYPE = 'postgres';
process.env.DB_HOST = postgresContainer.getHost();
process.env.DB_PORT = String(postgresContainer.getPort());
process.env.DB_USER = postgresContainer.getUsername();
process.env.DB_PASSWORD = postgresContainer.getPassword();
process.env.DB_NAME = postgresContainer.getDatabase();
console.log(`PostgreSQL container started at ${process.env.DB_HOST}:${process.env.DB_PORT}`);
// Run migrations once for all tests
console.log('Running database migrations...');
const db = new Kysely<Database>({
dialect: createDatabaseDialect({
type: 'postgres',
host: postgresContainer.getHost(),
port: postgresContainer.getPort(),
user: postgresContainer.getUsername(),
password: postgresContainer.getPassword(),
database: postgresContainer.getDatabase(),
}),
});
// Dynamically import all migration files from the migrations folder
const migrationsDir = join(process.cwd(), 'src/db/migrations');
const files = await readdir(migrationsDir);
const migrationFiles = files.filter((file) => file.endsWith('.ts'));
// biome-ignore lint/suspicious/noExplicitAny: We need to use any here for dynamic imports
const migrations: Record<string, any> = {};
for (const file of migrationFiles) {
const migrationName = file.replace('.ts', '');
const migrationPath = join(migrationsDir, file);
const module = await import(migrationPath);
migrations[migrationName] = module;
}
const migrator = new Migrator({
db,
provider: {
async getMigrations() {
return migrations;
},
},
});
const { error, results } = await migrator.migrateToLatest();
if (error) {
console.error('Migration failed:', error);
throw error;
}
if (results) {
for (const result of results) {
if (result.status === 'Success') {
console.log(`Migration "${result.migrationName}" executed successfully`);
} else if (result.status === 'Error') {
console.error(`Migration "${result.migrationName}" failed`);
}
}
}
await db.destroy();
console.log('Database migrations completed');
}
export async function teardown() {
console.log('Stopping PostgreSQL testcontainer...');
await postgresContainer?.stop();
console.log('PostgreSQL container stopped');
}

View File

@ -0,0 +1,22 @@
import { afterEach, beforeEach, expect, test } from 'vitest';
import { getConfig } from '@/config/config.js';
import type { Server } from '@/config/types.js';
import { getServer } from '@/server.js';
let server: Server;
beforeEach(async () => {
const config = getConfig();
server = getServer(config);
await server.ready();
});
afterEach(async () => {
await server.close();
});
test('should return ok status', async () => {
const response = await server.inject({ url: '/status' });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ msg: 'Server is up and running fine!' });
});

View File

@ -0,0 +1,11 @@
{
"extends": "@olli/ts-config/base.json",
"compilerOptions": {
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"include": ["index.js", "index.ts", "src/**/*.ts", "test/**/*.ts"]
}

View File

@ -0,0 +1,37 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
const pathAliases = {
alias: {
'@': path.resolve(__dirname, './src'),
},
};
export default defineConfig({
test: {
env: {
PINO_LOG_LEVEL: 'silent',
},
environment: 'node',
coverage: {
reporter: ['text', 'html', 'clover', 'json'],
},
projects: [
{
test: {
name: 'unit',
include: ['./test/**/*.unit.test.ts'],
},
resolve: pathAliases,
},
{
test: {
name: 'integration',
include: ['./test/**/*.integration.test.ts'],
globalSetup: './test/global-setup.ts',
},
resolve: pathAliases,
},
],
},
});

36
apps/docs/.gitignore vendored
View File

@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,50 +0,0 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
.imgLight {
display: none;
}
.imgDark {
display: unset;
}
}

View File

@ -1,31 +0,0 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View File

@ -1,186 +0,0 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-synthesis: none;
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
button.secondary {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
background: transparent;
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
font-family: var(--font-geist-sans);
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}

View File

@ -1,102 +0,0 @@
import Image, { type ImageProps } from "next/image";
import { Button } from "@repo/ui/button";
import styles from "./page.module.css";
type Props = Omit<ImageProps, "src"> & {
srcLight: string;
srcDark: string;
};
const ThemeImage = (props: Props) => {
const { srcLight, srcDark, ...rest } = props;
return (
<>
<Image {...rest} src={srcLight} className="imgLight" />
<Image {...rest} src={srcDark} className="imgDark" />
</>
);
};
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<ThemeImage
className={styles.logo}
srcLight="turborepo-dark.svg"
srcDark="turborepo-light.svg"
alt="Turborepo logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>apps/docs/app/page.tsx</code>
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new/clone?demo-description=Learn+to+implement+a+monorepo+with+a+two+Next.js+sites+that+has+installed+three+local+packages.&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4K8ZISWAzJ8X1504ca0zmC%2F0b21a1c6246add355e55816278ef54bc%2FBasic.png&demo-title=Monorepo+with+Turborepo&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Monorepo+with+Turborepo&repository-name=monorepo-turborepo&repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fturborepo%2Ftree%2Fmain%2Fexamples%2Fbasic&root-directory=apps%2Fdocs&skippable-integrations=1&teamSlug=vercel&utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://turborepo.com/docs?utm_source"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
<Button appName="docs" className={styles.secondary}>
Open alert
</Button>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com/templates?search=turborepo&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://turborepo.com?utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to turborepo.com
</a>
</footer>
</div>
);
}

View File

@ -1,4 +0,0 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default nextJsConfig;

View File

@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

View File

@ -1,28 +0,0 @@
{
"name": "docs",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@repo/ui": "workspace:*",
"next": "^16.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}
}

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 645 B

View File

@ -1,10 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_868_525)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_868_525">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6565V22.3773H91.0977V30.6565H106.16V58.1875H115.935V30.6565H130.998Z" fill="black"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2275V22.3773H162.768V41.2799C162.768 47.0155 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0155 144.315 41.2799V22.3773H134.539V42.2275C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="black"/>
<path d="M187.508 46.3173H197.234L204.914 58.1875H216.136L207.458 45.2699C212.346 43.5243 215.338 39.634 215.338 34.3473C215.338 26.6665 209.603 22.3773 200.874 22.3773H177.732V58.1875H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.053 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="black"/>
<path d="M219.887 58.1875H245.472C253.452 58.1875 258.041 54.397 258.041 48.0629C258.041 43.8235 255.348 40.9308 252.156 39.634C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1875ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0205 248.615 46.9657C248.615 48.9108 247.168 50.2075 244.525 50.2075H229.263V43.7238Z" fill="black"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.786 281.942 58.786C294.461 58.786 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2575C288.525 30.2575 293.463 34.1478 293.463 40.2824C293.463 46.417 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.417 270.421 40.2824C270.421 34.1478 275.359 30.2575 281.942 30.2575Z" fill="black"/>
<path d="M317.526 46.3173H327.251L334.932 58.1875H346.154L337.476 45.2699C342.364 43.5243 345.356 39.634 345.356 34.3473C345.356 26.6665 339.62 22.3773 330.892 22.3773H307.75V58.1875H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.053 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="black"/>
<path d="M349.904 22.3773V58.1875H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6565H384.717V22.3773H349.904Z" fill="black"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1875H399.204V46.7662ZM399.204 38.6365V30.5568H411.673C415.164 30.5568 417.059 32.053 417.059 34.5967C417.059 37.0904 415.164 38.6365 411.673 38.6365H399.204Z" fill="black"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.786 450.948 58.786C463.467 58.786 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2575C457.532 30.2575 462.469 34.1478 462.469 40.2824C462.469 46.417 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.417 439.427 40.2824C439.427 34.1478 444.365 30.2575 450.948 30.2575Z" fill="black"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_278)"/>
<defs>
<linearGradient id="paint0_linear_2028_278" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6566V22.3773H91.0977V30.6566H106.16V58.1876H115.935V30.6566H130.998Z" fill="white"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2276V22.3773H162.768V41.2799C162.768 47.0156 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0156 144.315 41.2799V22.3773H134.539V42.2276C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="white"/>
<path d="M187.508 46.3173H197.234L204.914 58.1876H216.136L207.458 45.2699C212.346 43.5243 215.338 39.6341 215.338 34.3473C215.338 26.6666 209.603 22.3773 200.874 22.3773H177.732V58.1876H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.0531 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="white"/>
<path d="M219.887 58.1876H245.472C253.452 58.1876 258.041 54.3971 258.041 48.0629C258.041 43.8236 255.348 40.9308 252.156 39.6341C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1876ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0206 248.615 46.9657C248.615 48.9108 247.168 50.2076 244.525 50.2076H229.263V43.7238Z" fill="white"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.7861 281.942 58.7861C294.461 58.7861 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2576C288.525 30.2576 293.463 34.1478 293.463 40.2824C293.463 46.4171 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.4171 270.421 40.2824C270.421 34.1478 275.359 30.2576 281.942 30.2576Z" fill="white"/>
<path d="M317.526 46.3173H327.251L334.932 58.1876H346.154L337.476 45.2699C342.364 43.5243 345.356 39.6341 345.356 34.3473C345.356 26.6666 339.62 22.3773 330.892 22.3773H307.75V58.1876H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.0531 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="white"/>
<path d="M349.904 22.3773V58.1876H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6566H384.717V22.3773H349.904Z" fill="white"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1876H399.204V46.7662ZM399.204 38.6366V30.5568H411.673C415.164 30.5568 417.059 32.0531 417.059 34.5967C417.059 37.0904 415.164 38.6366 411.673 38.6366H399.204Z" fill="white"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.7861 450.948 58.7861C463.467 58.7861 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2576C457.532 30.2576 462.469 34.1478 462.469 40.2824C462.469 46.4171 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.4171 439.427 40.2824C439.427 34.1478 444.365 30.2576 450.948 30.2576Z" fill="white"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_477)"/>
<defs>
<linearGradient id="paint0_linear_2028_477" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,10 +0,0 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_977_547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_977_547">
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 750 B

View File

@ -1,20 +0,0 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,2 @@
# Backend API URL
VITE_BACKEND_URL=http://localhost:3003

28
apps/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,58 @@
# Person and Pet CRUD UI
This is a simple CRUD (Create, Read, Update, Delete) application for managing persons and their pets, built with SolidJS and using the `api-client-backend` package for API communication.
## Features
### Person Management
- **Create** new persons with first name, last name, gender, and plan (free/premium)
- **Read/List** all persons with their details
- **Update** existing person information
- **Delete** persons
### Pet Management
- **Create** new pets with name, species (dog/cat), and owner assignment
- **Read/List** all pets with their owners
- **Update** existing pet information
- **Delete** pets
## Running the Application
1. **Start the backend server** (must be running on `http://localhost:3000`):
```bash
cd apps/backend
pnpm run dev
```
2. **Start the frontend development server**:
```bash
cd apps/frontend
pnpm run dev
```
3. Open your browser and navigate to the URL shown in the terminal (typically `http://localhost:5173`)
## Tech Stack
- **SolidJS** - Reactive UI framework
- **api-client-backend** - Type-safe API client generated from OpenAPI spec
- **Vite** - Build tool and dev server
## Application Structure
- `src/app.tsx` - Main application component with tab navigation
- `src/person-crud.tsx` - Person management CRUD component
- `src/pet-crud.tsx` - Pet management CRUD component
- `src/api.ts` - API client configuration
- `src/index.tsx` - Application entry point
## API Configuration
The API client is configured to connect to `http://localhost:3000`. To change this, edit [src/api.ts](src/api.ts#L4-L6).
## Notes
- You must create at least one person before you can create a pet (pets need an owner)
- All form validations are in place to ensure data integrity
- The UI features inline editing and creation forms
- Real-time updates after create, update, or delete operations

16
apps/frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Solid App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@ -0,0 +1,30 @@
{
"name": "@olli/frontend",
"version": "0.0.0",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"dev:watch": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "vitest run",
"test:watch": "vitest --watch"
},
"license": "MIT",
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.1",
"solid-devtools": "^0.34.3",
"typescript": "^5.7.2",
"vite": "^7.1.4",
"vite-plugin-solid": "2.11.9",
"vitest": "^4.0.0"
},
"dependencies": {
"solid-js": "^1.9.5",
"@olli/api-client-backend": "workspace:*"
}
}

12
apps/frontend/src/api.ts Normal file
View File

@ -0,0 +1,12 @@
import { client } from '@olli/api-client-backend/client';
// Configure the API client with the backend URL from environment variable
// In Vite, import.meta.env is used to access environment variables
// VITE_ prefix is required for client-side exposure
const baseUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3003';
client.setConfig({
baseUrl,
});
export * from '@olli/api-client-backend';

99
apps/frontend/src/app.tsx Normal file
View File

@ -0,0 +1,99 @@
import { createSignal, Show } from 'solid-js';
import { PersonCRUD } from './person-crud';
import { PetCRUD } from './pet-crud';
type Tab = 'persons' | 'pets';
export function App() {
const [activeTab, setActiveTab] = createSignal<Tab>('persons');
return (
<div style={{ 'font-family': 'system-ui, sans-serif' }}>
<header
style={{
background: '#333',
color: 'white',
padding: '20px',
'box-shadow': '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<h1 style={{ margin: 0 }}>CRUD Demo Application</h1>
<p style={{ margin: '5px 0 0 0', opacity: 0.8 }}>
Manage persons and their pets pu
</p>
</header>
<nav
style={{
background: '#f5f5f5',
padding: '0',
'border-bottom': '2px solid #ddd',
}}
>
<div style={{ display: 'flex', 'max-width': '1200px', margin: '0 auto' }}>
<button
type="button"
onClick={() => setActiveTab('persons')}
style={{
padding: '15px 30px',
border: 'none',
background: activeTab() === 'persons' ? 'white' : 'transparent',
cursor: 'pointer',
'border-bottom':
activeTab() === 'persons'
? '3px solid #2196F3'
: '3px solid transparent',
'font-size': '16px',
'font-weight': activeTab() === 'persons' ? 'bold' : 'normal',
transition: 'all 0.3s',
}}
>
👤 Persons
</button>
<button
type="button"
onClick={() => setActiveTab('pets')}
style={{
padding: '15px 30px',
border: 'none',
background: activeTab() === 'pets' ? 'white' : 'transparent',
cursor: 'pointer',
'border-bottom':
activeTab() === 'pets'
? '3px solid #2196F3'
: '3px solid transparent',
'font-size': '16px',
'font-weight': activeTab() === 'pets' ? 'bold' : 'normal',
transition: 'all 0.3s',
}}
>
🐾 Pets
</button>
</div>
</nav>
<main>
<Show when={activeTab() === 'persons'}>
<PersonCRUD />
</Show>
<Show when={activeTab() === 'pets'}>
<PetCRUD />
</Show>
</main>
<footer
style={{
background: '#f5f5f5',
padding: '20px',
'text-align': 'center',
'margin-top': '40px',
'border-top': '1px solid #ddd',
}}
>
<p style={{ margin: 0, color: '#666' }}>
Built with SolidJS and api-client-backend
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { render } from 'solid-js/web';
import 'solid-devtools';
import { App } from './app';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?'
);
}
if (!root) throw new Error('Root element not found');
render(() => <App />, root);

View File

@ -0,0 +1,408 @@
import { createResource, createSignal, For, Show } from 'solid-js';
import {
deleteDemoPersonsById,
type GetDemoPersonsResponse,
getDemoPersons,
patchDemoPersonsById,
postDemoPersons,
} from './api';
type Person = GetDemoPersonsResponse[number];
export function PersonCRUD() {
const [editingId, setEditingId] = createSignal<number | null>(null);
const [isCreating, setIsCreating] = createSignal(false);
// Fetch persons
const [persons, { refetch }] = createResource<Person[]>(async () => {
const response = await getDemoPersons();
return response.data ?? [];
});
// Form state
const [formData, setFormData] = createSignal({
first_name: '',
last_name: '',
gender: 'other' as 'man' | 'woman' | 'other',
metadata: {
login_at: new Date().toISOString(),
ip: null as string | null,
agent: null as string | null,
plan: 'free' as 'free' | 'premium',
},
});
const resetForm = () => {
setFormData({
first_name: '',
last_name: '',
gender: 'other',
metadata: {
login_at: new Date().toISOString(),
ip: null,
agent: null,
plan: 'free',
},
});
setEditingId(null);
setIsCreating(false);
};
const handleCreate = async (e: Event) => {
e.preventDefault();
try {
await postDemoPersons({
body: formData(),
});
resetForm();
refetch();
} catch (error) {
console.error('Failed to create person:', error);
}
};
const handleUpdate = async (e: Event) => {
e.preventDefault();
const id = editingId();
if (id === null) return;
try {
await patchDemoPersonsById({
path: { id },
body: formData(),
});
resetForm();
refetch();
} catch (error) {
console.error('Failed to update person:', error);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this person?')) return;
try {
await deleteDemoPersonsById({
path: { id },
});
refetch();
} catch (error) {
console.error('Failed to delete person:', error);
}
};
const startEdit = (person: Person) => {
setFormData({
first_name: person.first_name,
last_name: person.last_name ?? '',
gender: person.gender,
metadata: person.metadata,
});
setEditingId(person.id);
setIsCreating(false);
};
const startCreate = () => {
resetForm();
setIsCreating(true);
};
return (
<div style={{ padding: '20px', 'max-width': '1200px', margin: '0 auto' }}>
<h2>Person Management</h2>
{/* Create/Edit Form */}
<Show when={isCreating() || editingId() !== null}>
<div
style={{
border: '1px solid #ccc',
padding: '20px',
'margin-bottom': '20px',
'border-radius': '8px',
background: '#f9f9f9',
}}
>
<h3>{editingId() !== null ? 'Edit Person' : 'Create Person'}</h3>
<form onSubmit={editingId() !== null ? handleUpdate : handleCreate}>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
First Name *
<input
type="text"
value={formData().first_name}
onInput={(e) =>
setFormData({
...formData(),
first_name: e.currentTarget.value,
})
}
required
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
/>
</label>
</div>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Last Name
<input
type="text"
value={formData().last_name}
onInput={(e) =>
setFormData({
...formData(),
last_name: e.currentTarget.value,
})
}
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
/>
</label>
</div>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Gender *
<select
value={formData().gender}
onChange={(e) =>
setFormData({
...formData(),
gender: e.currentTarget.value as
| 'man'
| 'woman'
| 'other',
})
}
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
>
<option value="man">Man</option>
<option value="woman">Woman</option>
<option value="other">Other</option>
</select>
</label>
</div>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Plan
<select
value={formData().metadata.plan}
onChange={(e) =>
setFormData({
...formData(),
metadata: {
...formData().metadata,
plan: e.currentTarget.value as 'free' | 'premium',
},
})
}
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
>
<option value="free">Free</option>
<option value="premium">Premium</option>
</select>
</label>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="submit"
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
{editingId() !== null ? 'Update' : 'Create'}
</button>
<button
type="button"
onClick={resetForm}
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
Cancel
</button>
</div>
</form>
</div>
</Show>
{/* Create Button */}
<Show when={!isCreating() && editingId() === null}>
<button
type="button"
onClick={startCreate}
style={{
padding: '10px 20px',
'margin-bottom': '20px',
cursor: 'pointer',
background: '#4CAF50',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
+ Create New Person
</button>
</Show>
{/* List */}
<div>
<Show when={persons.loading}>
<p>Loading persons...</p>
</Show>
<Show when={persons.error}>
<p style={{ color: 'red' }}>Error loading persons: {persons.error.message}</p>
</Show>
<Show when={persons()}>
<table
style={{
width: '100%',
'border-collapse': 'collapse',
border: '1px solid #ddd',
}}
>
<thead>
<tr style={{ background: '#f2f2f2' }}>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
ID
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
First Name
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Last Name
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Gender
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Plan
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Created At
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Actions
</th>
</tr>
</thead>
<tbody>
<For each={persons()}>
{(person) => (
<tr>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{person.id}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{person.first_name}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{person.last_name ?? '-'}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{person.gender}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{person.metadata.plan}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{new Date(person.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
<button
type="button"
onClick={() => startEdit(person)}
style={{
padding: '6px 12px',
'margin-right': '5px',
cursor: 'pointer',
background: '#2196F3',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(person.id)}
style={{
padding: '6px 12px',
cursor: 'pointer',
background: '#f44336',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
Delete
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</div>
);
}

View File

@ -0,0 +1,360 @@
import { createResource, createSignal, For, Show } from 'solid-js';
import {
deleteDemoPetsById,
type GetDemoPetsResponse,
getDemoPersons,
getDemoPets,
patchDemoPetsById,
postDemoPets,
} from './api';
type Pet = GetDemoPetsResponse[number];
export function PetCRUD() {
const [editingId, setEditingId] = createSignal<number | null>(null);
const [isCreating, setIsCreating] = createSignal(false);
// Fetch pets
const [pets, { refetch }] = createResource<Pet[]>(async () => {
const response = await getDemoPets();
return response.data ?? [];
});
// Fetch persons for the owner dropdown
const [persons] = createResource(async () => {
const response = await getDemoPersons();
return response.data ?? [];
});
// Form state
const [formData, setFormData] = createSignal({
name: '',
owner_id: 0,
species: 'dog' as 'dog' | 'cat',
});
const resetForm = () => {
setFormData({
name: '',
owner_id: 0,
species: 'dog',
});
setEditingId(null);
setIsCreating(false);
};
const handleCreate = async (e: Event) => {
e.preventDefault();
try {
await postDemoPets({
body: formData(),
});
resetForm();
refetch();
} catch (error) {
console.error('Failed to create pet:', error);
}
};
const handleUpdate = async (e: Event) => {
e.preventDefault();
const id = editingId();
if (id === null) return;
try {
await patchDemoPetsById({
path: { id },
body: formData(),
});
resetForm();
refetch();
} catch (error) {
console.error('Failed to update pet:', error);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this pet?')) return;
try {
await deleteDemoPetsById({
path: { id },
});
refetch();
} catch (error) {
console.error('Failed to delete pet:', error);
}
};
const startEdit = (pet: Pet) => {
setFormData({
name: pet.name,
owner_id: pet.owner_id,
species: pet.species,
});
setEditingId(pet.id);
setIsCreating(false);
};
const startCreate = () => {
resetForm();
setIsCreating(true);
};
const getOwnerName = (ownerId: number) => {
const owner = persons()?.find((p) => p.id === ownerId);
return owner ? `${owner.first_name} ${owner.last_name ?? ''}`.trim() : `ID: ${ownerId}`;
};
return (
<div style={{ padding: '20px', 'max-width': '1200px', margin: '0 auto' }}>
<h2>Pet Management</h2>
{/* Create/Edit Form */}
<Show when={isCreating() || editingId() !== null}>
<div
style={{
border: '1px solid #ccc',
padding: '20px',
'margin-bottom': '20px',
'border-radius': '8px',
background: '#f9f9f9',
}}
>
<h3>{editingId() !== null ? 'Edit Pet' : 'Create Pet'}</h3>
<form onSubmit={editingId() !== null ? handleUpdate : handleCreate}>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Name *
<input
type="text"
value={formData().name}
onInput={(e) =>
setFormData({ ...formData(), name: e.currentTarget.value })
}
required
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
/>
</label>
</div>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Owner *
<select
value={formData().owner_id}
onChange={(e) =>
setFormData({
...formData(),
owner_id: Number.parseInt(e.currentTarget.value),
})
}
required
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
>
<option value={0}>Select an owner</option>
<For each={persons()}>
{(person) => (
<option value={person.id}>
{person.first_name} {person.last_name ?? ''} (ID:{' '}
{person.id})
</option>
)}
</For>
</select>
</label>
</div>
<div style={{ 'margin-bottom': '10px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px' }}>
Species *
<select
value={formData().species}
onChange={(e) =>
setFormData({
...formData(),
species: e.currentTarget.value as 'dog' | 'cat',
})
}
style={{
width: '100%',
padding: '8px',
'box-sizing': 'border-box',
}}
>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
</select>
</label>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="submit"
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
{editingId() !== null ? 'Update' : 'Create'}
</button>
<button
type="button"
onClick={resetForm}
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
Cancel
</button>
</div>
</form>
</div>
</Show>
{/* Create Button */}
<Show when={!isCreating() && editingId() === null}>
<button
type="button"
onClick={startCreate}
style={{
padding: '10px 20px',
'margin-bottom': '20px',
cursor: 'pointer',
background: '#4CAF50',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
+ Create New Pet
</button>
</Show>
{/* List */}
<div>
<Show when={pets.loading || persons.loading}>
<p>Loading pets...</p>
</Show>
<Show when={pets.error}>
<p style={{ color: 'red' }}>Error loading pets: {pets.error.message}</p>
</Show>
<Show when={pets() && persons()}>
<table
style={{
width: '100%',
'border-collapse': 'collapse',
border: '1px solid #ddd',
}}
>
<thead>
<tr style={{ background: '#f2f2f2' }}>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
ID
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Name
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Species
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Owner
</th>
<th
style={{
padding: '12px',
'text-align': 'left',
border: '1px solid #ddd',
}}
>
Actions
</th>
</tr>
</thead>
<tbody>
<For each={pets()}>
{(pet) => (
<tr>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{pet.id}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{pet.name}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{pet.species === 'dog' ? '🐕 Dog' : '🐈 Cat'}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
{getOwnerName(pet.owner_id)}
</td>
<td style={{ padding: '12px', border: '1px solid #ddd' }}>
<button
type="button"
onClick={() => startEdit(pet)}
style={{
padding: '6px 12px',
'margin-right': '5px',
cursor: 'pointer',
background: '#2196F3',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(pet.id)}
style={{
padding: '6px 12px',
cursor: 'pointer',
background: '#f44336',
color: 'white',
border: 'none',
'border-radius': '4px',
}}
>
Delete
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { fireEvent, render } from '@solidjs/testing-library';
import { describe, expect, test } from 'vitest';
import { TodoList } from './todo-list';
describe('<TodoList />', () => {
test('it will render an text input and a button', () => {
const { getByPlaceholderText, getByText } = render(() => <TodoList />);
expect(getByPlaceholderText('new todo here')).toBeInTheDocument();
expect(getByText('Add Todo')).toBeInTheDocument();
});
test('it will add a new todo', async () => {
const { getByPlaceholderText, getByText } = render(() => <TodoList />);
const input = getByPlaceholderText('new todo here') as HTMLInputElement;
const button = getByText('Add Todo');
input.value = 'test new todo';
fireEvent.click(button as HTMLInputElement);
expect(input.value).toBe('');
expect(getByText(/test new todo/)).toBeInTheDocument();
});
test('it will mark a todo as completed', async () => {
const { getByPlaceholderText, findByRole, getByText } = render(() => <TodoList />);
const input = getByPlaceholderText('new todo here') as HTMLInputElement;
const button = getByText('Add Todo') as HTMLButtonElement;
input.value = 'mark new todo as completed';
fireEvent.click(button);
const completed = (await findByRole('checkbox')) as HTMLInputElement;
expect(completed?.checked).toBe(false);
fireEvent.click(completed);
expect(completed?.checked).toBe(true);
const text = getByText('mark new todo as completed') as HTMLSpanElement;
expect(text).toHaveStyle({ 'text-decoration': 'line-through' });
});
});

View File

@ -0,0 +1,56 @@
import { For } from 'solid-js';
import { createStore } from 'solid-js/store';
type Todo = { id: number; text: string; completed: boolean };
export const TodoList = () => {
let input!: HTMLInputElement;
const [todos, setTodos] = createStore<Todo[]>([]);
const addTodo = (text: string) => {
setTodos(todos.length, { id: todos.length, text, completed: false });
};
const toggleTodo = (id: number) => {
setTodos(id, 'completed', (c) => !c);
};
return (
<>
<div>
<input placeholder="new todo here" ref={input} />
<button
type="button"
onClick={() => {
if (!input.value.trim()) return;
addTodo(input.value);
input.value = '';
}}
>
Add Todo
</button>
</div>
<div>
<For each={todos}>
{(todo) => {
const { id, text } = todo;
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onchange={[toggleTodo, id]}
/>
<span
style={{
'text-decoration': todo.completed ? 'line-through' : 'none',
}}
>
{text}
</span>
</div>
);
}}
</For>
</div>
</>
);
};

10
apps/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_URL: string;
}
// biome-ignore lint/correctness/noUnusedVariables: This gets used by Vite
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
// General
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
// Modules
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "ESNext",
"moduleResolution": "bundler",
// Type Checking & Safety
"strict": true,
"types": ["vite/client", "@testing-library/jest-dom"]
}
}

View File

@ -0,0 +1,27 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import devtools from 'solid-devtools/vite';
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [devtools(), solidPlugin()],
server: {
port: 3001,
},
test: {
environment: 'jsdom',
globals: false,
setupFiles: ['node_modules/@testing-library/jest-dom/vitest'],
// if you have few tests, try commenting this
// out to improve performance:
isolate: false,
},
build: {
target: 'esnext',
},
resolve: {
conditions: ['development', 'browser'],
},
});

36
apps/web/.gitignore vendored
View File

@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,50 +0,0 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
.imgLight {
display: none;
}
.imgDark {
display: unset;
}
}

View File

@ -1,31 +0,0 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View File

@ -1,186 +0,0 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-synthesis: none;
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
button.secondary {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
font-family: var(--font-geist-sans);
border: 1px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
background: transparent;
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
font-family: var(--font-geist-sans);
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}

View File

@ -1,102 +0,0 @@
import Image, { type ImageProps } from "next/image";
import { Button } from "@repo/ui/button";
import styles from "./page.module.css";
type Props = Omit<ImageProps, "src"> & {
srcLight: string;
srcDark: string;
};
const ThemeImage = (props: Props) => {
const { srcLight, srcDark, ...rest } = props;
return (
<>
<Image {...rest} src={srcLight} className="imgLight" />
<Image {...rest} src={srcDark} className="imgDark" />
</>
);
};
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<ThemeImage
className={styles.logo}
srcLight="turborepo-dark.svg"
srcDark="turborepo-light.svg"
alt="Turborepo logo"
width={180}
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>apps/web/app/page.tsx</code>
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new/clone?demo-description=Learn+to+implement+a+monorepo+with+a+two+Next.js+sites+that+has+installed+three+local+packages.&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4K8ZISWAzJ8X1504ca0zmC%2F0b21a1c6246add355e55816278ef54bc%2FBasic.png&demo-title=Monorepo+with+Turborepo&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Monorepo+with+Turborepo&repository-name=monorepo-turborepo&repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fturborepo%2Ftree%2Fmain%2Fexamples%2Fbasic&root-directory=apps%2Fdocs&skippable-integrations=1&teamSlug=vercel&utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://turborepo.com/docs?utm_source"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
<Button appName="web" className={styles.secondary}>
Open alert
</Button>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com/templates?search=turborepo&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://turborepo.com?utm_source=create-turbo"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to turborepo.com
</a>
</footer>
</div>
);
}

View File

@ -1,4 +0,0 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default nextJsConfig;

View File

@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

View File

@ -1,28 +0,0 @@
{
"name": "web",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@repo/ui": "workspace:*",
"next": "^16.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}
}

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 645 B

View File

@ -1,10 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_868_525)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_868_525">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6565V22.3773H91.0977V30.6565H106.16V58.1875H115.935V30.6565H130.998Z" fill="black"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2275V22.3773H162.768V41.2799C162.768 47.0155 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0155 144.315 41.2799V22.3773H134.539V42.2275C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="black"/>
<path d="M187.508 46.3173H197.234L204.914 58.1875H216.136L207.458 45.2699C212.346 43.5243 215.338 39.634 215.338 34.3473C215.338 26.6665 209.603 22.3773 200.874 22.3773H177.732V58.1875H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.053 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="black"/>
<path d="M219.887 58.1875H245.472C253.452 58.1875 258.041 54.397 258.041 48.0629C258.041 43.8235 255.348 40.9308 252.156 39.634C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1875ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0205 248.615 46.9657C248.615 48.9108 247.168 50.2075 244.525 50.2075H229.263V43.7238Z" fill="black"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.786 281.942 58.786C294.461 58.786 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2575C288.525 30.2575 293.463 34.1478 293.463 40.2824C293.463 46.417 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.417 270.421 40.2824C270.421 34.1478 275.359 30.2575 281.942 30.2575Z" fill="black"/>
<path d="M317.526 46.3173H327.251L334.932 58.1875H346.154L337.476 45.2699C342.364 43.5243 345.356 39.634 345.356 34.3473C345.356 26.6665 339.62 22.3773 330.892 22.3773H307.75V58.1875H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.053 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="black"/>
<path d="M349.904 22.3773V58.1875H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6565H384.717V22.3773H349.904Z" fill="black"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1875H399.204V46.7662ZM399.204 38.6365V30.5568H411.673C415.164 30.5568 417.059 32.053 417.059 34.5967C417.059 37.0904 415.164 38.6365 411.673 38.6365H399.204Z" fill="black"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.786 450.948 58.786C463.467 58.786 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2575C457.532 30.2575 462.469 34.1478 462.469 40.2824C462.469 46.417 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.417 439.427 40.2824C439.427 34.1478 444.365 30.2575 450.948 30.2575Z" fill="black"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_278)"/>
<defs>
<linearGradient id="paint0_linear_2028_278" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,19 +0,0 @@
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130.998 30.6566V22.3773H91.0977V30.6566H106.16V58.1876H115.935V30.6566H130.998Z" fill="white"/>
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2276V22.3773H162.768V41.2799C162.768 47.0156 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0156 144.315 41.2799V22.3773H134.539V42.2276C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="white"/>
<path d="M187.508 46.3173H197.234L204.914 58.1876H216.136L207.458 45.2699C212.346 43.5243 215.338 39.6341 215.338 34.3473C215.338 26.6666 209.603 22.3773 200.874 22.3773H177.732V58.1876H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.0531 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="white"/>
<path d="M219.887 58.1876H245.472C253.452 58.1876 258.041 54.3971 258.041 48.0629C258.041 43.8236 255.348 40.9308 252.156 39.6341C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1876ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0206 248.615 46.9657C248.615 48.9108 247.168 50.2076 244.525 50.2076H229.263V43.7238Z" fill="white"/>
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.7861 281.942 58.7861C294.461 58.7861 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2576C288.525 30.2576 293.463 34.1478 293.463 40.2824C293.463 46.4171 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.4171 270.421 40.2824C270.421 34.1478 275.359 30.2576 281.942 30.2576Z" fill="white"/>
<path d="M317.526 46.3173H327.251L334.932 58.1876H346.154L337.476 45.2699C342.364 43.5243 345.356 39.6341 345.356 34.3473C345.356 26.6666 339.62 22.3773 330.892 22.3773H307.75V58.1876H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.0531 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="white"/>
<path d="M349.904 22.3773V58.1876H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6566H384.717V22.3773H349.904Z" fill="white"/>
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1876H399.204V46.7662ZM399.204 38.6366V30.5568H411.673C415.164 30.5568 417.059 32.0531 417.059 34.5967C417.059 37.0904 415.164 38.6366 411.673 38.6366H399.204Z" fill="white"/>
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.7861 450.948 58.7861C463.467 58.7861 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2576C457.532 30.2576 462.469 34.1478 462.469 40.2824C462.469 46.4171 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.4171 439.427 40.2824C439.427 34.1478 444.365 30.2576 450.948 30.2576Z" fill="white"/>
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_477)"/>
<defs>
<linearGradient id="paint0_linear_2028_477" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
<stop stop-color="#0096FF"/>
<stop offset="1" stop-color="#FF1E56"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,10 +0,0 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_977_547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_977_547">
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 750 B

View File

@ -1,20 +0,0 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

70
biome.json Normal file
View File

@ -0,0 +1,70 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!**/dist",
"!**/build",
"!**/node_modules",
"!**/coverage",
"!**/*.min.js"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 100
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"useConsistentArrayType": "error",
"useImportType": "error",
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"trailingCommas": "es5",
"bracketSameLine": false,
"bracketSpacing": true,
"arrowParentheses": "always"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

39
docker-compose.yaml Normal file
View File

@ -0,0 +1,39 @@
services:
postgres:
image: postgres:18
environment:
POSTGRES_DB: ${POSTGRES_DB:-backend}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testing123}
PGDATA: /data/postgres
volumes:
- postgres:/data/postgres
ports:
- "5432:5432"
networks:
- postgres
restart: unless-stopped
pgadmin:
image: dpage/pgadmin4:9.10
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin}
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
volumes:
- pgadmin:/var/lib/pgadmin
- ./pgadmin.json:/pgadmin4/servers.json
ports:
- "${PGADMIN_PORT:-5050}:80"
networks:
- postgres
restart: unless-stopped
networks:
postgres:
driver: bridge
volumes:
postgres:
pgadmin:

View File

@ -1,20 +1,22 @@
{
"name": "turbo-monorepo-test",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.6.3",
"typescript": "5.9.2"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
}
"name": "turbo-monorepo-test",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"dev:watch": "turbo watch export-openapi generate dev:watch",
"lint": "turbo run lint",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"check-types": "turbo run check-types"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"turbo": "^2.6.3",
"typescript": "5.9.2"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
}
}

View File

@ -0,0 +1,2 @@
node_modules
generated

View File

@ -0,0 +1,95 @@
# @olli/api-client-backend
Type-safe API client for the backend service, auto-generated from OpenAPI spec using `@hey-api/openapi-ts`.
This is a **just-in-time (JIT) package** - consuming apps compile the TypeScript source directly for optimal performance.
## Installation
Add to your app's dependencies:
```json
{
"dependencies": {
"@olli/api-client-backend": "workspace:*"
}
}
```
## Usage
### Using SDK Functions (Recommended)
```typescript
import { getDemoPersons, postDemoPersons } from '@olli/api-client-backend';
import { client } from '@olli/api-client-backend/client';
// Configure the client once
client.setConfig({
baseUrl: 'http://localhost:3003',
headers: {
'Authorization': 'Bearer token'
}
});
// Use type-safe SDK functions
const { data: persons } = await getDemoPersons();
const { data: newPerson } = await postDemoPersons({
body: {
first_name: 'John',
gender: 'man',
metadata: {
login_at: new Date().toISOString(),
ip: null,
agent: null,
plan: 'free'
}
}
});
```
### Direct Client Access
```typescript
import { client } from '@olli/api-client-backend/client';
const response = await client.get({ url: '/demo/persons' });
```
## Exports
- `@olli/api-client-backend` - All generated SDK functions and types
- `@olli/api-client-backend/client` - Client instance and configuration
## Development
### Generate Client
The client is automatically generated from the backend's OpenAPI spec:
```bash
# Generate from OpenAPI spec
turbo run generate --filter=@olli/api-client-backend
# Or manually
cd apps/backend && pnpm export-openapi
cd ../../packages/api-client-backend && pnpm generate
```
### Type Checking
```bash
pnpm check-types
```
## Architecture
This package follows Turborepo best practices as a **JIT (Just-In-Time) package**:
- ✅ No build step - TypeScript source consumed directly
- ✅ Faster hot reload in development
- ✅ Single compilation by consuming app
- ✅ Better tree-shaking and bundle optimization
- ✅ TypeScript project references for incremental builds

View File

@ -0,0 +1,10 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: './openapi.json',
output: {
path: './generated',
client: '@hey-api/client-fetch',
importFileExtension: '.js',
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "@olli/api-client-backend",
"private": true,
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts"
},
"scripts": {
"generate": "openapi-ts",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@hey-api/client-fetch": "^0.4.3"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.89.0",
"@olli/ts-config": "workspace:*",
"typescript": "^5.9.3"
}
}

View File

@ -0,0 +1 @@
export * from '../generated/client.gen.js';

View File

@ -0,0 +1,2 @@
// Re-export all generated types and SDK functions
export * from '../generated/index.js';

View File

@ -0,0 +1,8 @@
{
"extends": "@olli/ts-config/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*", "generated/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -1,3 +0,0 @@
# `@turbo/eslint-config`
Collection of internal eslint configurations.

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